Skip to content

feat: auto-restore terminal commands on session restore (part 1/2)#2545

Open
mrosnerr wants to merge 31 commits intomanaflow-ai:mainfrom
mrosnerr:feat/auto-restore-commands-core
Open

feat: auto-restore terminal commands on session restore (part 1/2)#2545
mrosnerr wants to merge 31 commits intomanaflow-ai:mainfrom
mrosnerr:feat/auto-restore-commands-core

Conversation

@mrosnerr
Copy link
Copy Markdown

@mrosnerr mrosnerr commented Apr 2, 2026

Ready for review — implementation complete, waiting on maintainer feedback before starting part 2 (Settings UI + translations).

Summary

Auto-restore safe terminal commands after a session restore (Sparkle update,
manual quit/relaunch). On quit, cmux detects the foreground process in each
terminal and stores it in the session snapshot. On restore, the command is
typed into the fresh shell automatically.

  • initialInput on TerminalSurface/TerminalPanel — one-shot startup
    typing, distinct from initialCommand (which replaces the shell entirely)
  • SessionForegroundProcessDetector — foreground process detection via
    ps + process group matching, run off main thread with os_unfair_lock
  • SessionForegroundProcessCache — async refresh during autosaves,
    blocking with 2s timeout on quit; only caches allowlisted commands
  • Allowlist/denylist in SessionRestoreCommandSettings — denylist runs
    first; blocks dangerous executables, credential patterns, CRLF injection,
    and absolute-path bypasses
  • Remote-backed terminals skipped entirely (no detection, no restore)
  • Session snapshot gains detectedCommand + isRemoteBacked per panel

Part 1 of 2 — core infrastructure. Settings UI + translations in part 2,
pending feedback here.

How It Works

  1. On quit: foreground process detected per TTY, allowlist-checked,
    stored in session-<bundleId>.json (Application Support)
  2. On restore: initialInput types the command + Enter into the new shell
  3. Safety: denylist blocks rm, git push --force, credential args,
    and similar before the allowlist is even consulted

Testing

  • Unit tests: allowlist/denylist matching, CRLF injection,
    credential redaction, snapshot round-trip
  • Manual: run opencode, quit, relaunch → command auto-restarts
  • Manual: run rm -rf /tmp/test, quit, relaunch → not restored

Related

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 2, 2026

@mrosnerr is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Add per-surface one-shot typed startup input for local terminals (consumed and cleared after first native surface creation); capture and validate per-TTY foreground commands with allowlist/denylist rules; persist per-panel restore state (including detectedCommand/isRemoteBacked) and inject validated restore text as initialInput during restore; add foreground-process caching and tests.

Changes

Cohort / File(s) Summary
Terminal init / startup input
Sources/GhosttyTerminalView.swift, Sources/Panels/TerminalPanel.swift
Add optional initialInput parameter to TerminalSurface and TerminalPanel initializers; accept only single-line non-empty input; resolved startup input prefers instance initialInput (appends "\n" when used) over baseConfig.initialInput; add DEBUG logging and clear initialInput after first successful native surface creation.
Session persistence & detection
Sources/SessionPersistence.swift, cmuxTests/SessionPersistenceTests.swift
Persist detectedCommand and isRemoteBacked; add SessionRestoreCommandSettings (allowlist parsing) and denylist safety checks; add SessionForegroundProcessDetector and SessionForegroundProcessCache (async/sync refresh, keyed by TTY); add extensive unit tests for allowlist/denylist/validation and remote-backed snapshot behavior.
Workspace & restore wiring
Sources/Workspace.swift
Detect remote-backed terminals and mark isRemoteBacked; expose allTerminalTTYNames() and use foreground-cache lookup only for local terminals; compute commandToRestore when settings enabled and not remote-backed; pass validated initialInput into newTerminalSurface(...); suppress initialInput when remote-startup command is active; clear stale TTYs for remote-upgrade scenarios.
Snapshot timing & termination
Sources/AppDelegate.swift, Sources/TabManager.swift
Refresh SessionForegroundProcessCache before building session snapshots (sync during termination, async otherwise); adjust isTerminatingApp handling to avoid marking termination on cancelled quit-confirmation; add TabManager.allTerminalTTYNames() aggregation helper.
Tests
cmuxTests/SessionPersistenceTests.swift
Add SessionRestoreCommandSettingsTests covering enable toggles, allowlist parsing/normalization/patterns, denylist precedence and matching semantics, trimming/newline rejection, and validated restore command behavior; add remote-backed snapshot tests.

Sequence Diagram

sequenceDiagram
    participant User
    participant App as AppDelegate
    participant Workspace
    participant Cache as SessionForegroundProcessCache
    participant Detector as SessionForegroundProcessDetector
    participant Snapshot as SessionPersistence
    participant Surface as TerminalSurface
    participant Shell

    rect rgba(100,150,200,0.5)
    Note over User,App: Snapshot capture
    User->>App: trigger saveSessionSnapshot
    App->>Workspace: buildSessionSnapshot
    Workspace->>Cache: refresh(ttyNames) (async/sync)
    Cache->>Detector: scan tty names
    Detector->>Detector: run /bin/ps and sysctl(KERN_PROCARGS2)
    Detector-->>Cache: store detected command for tty
    Cache-->>Snapshot: include detectedCommand (if allowed)
    end

    rect rgba(150,100,200,0.5)
    Note over User,Surface: Restore flow
    User->>Workspace: restore session
    Workspace->>Snapshot: read panelSnapshot
    Snapshot->>Snapshot: SessionRestoreCommandSettings.validatedRestoreCommand(...)
    Snapshot-->>Workspace: allowed restore text (if any)
    Workspace->>Surface: create terminal with initialInput (text + "\n")
    Surface->>Shell: inject initialInput at startup
    Shell->>Shell: execute restored input
    Surface-->>Workspace: clear initialInput after first native surface creation
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped a line, all neat and small,

One keystroke queued before the call.
I peeked at ttys, found who ran,
Allowed the kind, denied the banned.
Sent it once—then scurried off to nap.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding infrastructure to auto-restore terminal commands on session restore, and correctly labels it as part 1 of a 2-part series.
Description check ✅ Passed The PR description covers the summary, testing approach, and related issues, but lacks a demo video, bot review trigger command, and detailed checklist compliance.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 2, 2026

Greptile Summary

Adds auto-restore infrastructure for terminal session commands: foreground process detection via ps + KERN_PROCARGS2, allowlist/denylist validation, and initialInput threading through TerminalSurfaceTerminalPanelWorkspace. All prior review concerns (watch * bypass, isEnabled() skip, UTF-8 decoding, shell quoting) are addressed. One remaining P2: the "-p " substring in denylistContains will silently block ssh -p <port> host when SSH is added to the allowlist in Part 2 (see inline comment).

Confidence Score: 5/5

Safe to merge; the one remaining finding is a P2 that only affects future SSH support, not current functionality.

All P0/P1 concerns from prior rounds are resolved. The only unfixed issue is a P2 false-positive in denylistContains ("-p ") that doesn't affect any currently-allowed command and only becomes relevant when Part 2 adds SSH. Test coverage is thorough and the core detection/restore pipeline is logically sound.

Sources/SessionPersistence.swift — specifically the denylistContains "-p " and "-P " entries if SSH support is added in Part 2.

Important Files Changed

Filename Overview
Sources/SessionPersistence.swift Large addition: SessionRestoreCommandSettings (allowlist/denylist logic), SessionForegroundProcessCache, and SessionForegroundProcessDetector via ps + sysctl. Core logic is sound; minor: "-p " denylistContains pattern is too broad and will block ssh -p commands.
Sources/GhosttyTerminalView.swift Adds initialInput parameter with newline-injection protection, clears after first use, and logs debug byte-counts only. Implementation is clean and correct.
Sources/Workspace.swift Wires commandToRestore through session restore, suppresses initialInput for remote/SSH terminals, prunes panelRestoreCommands on panel removal, and skips TTY restoration from stale snapshots. Logic is correct after prior review fixes.
Sources/AppDelegate.swift Blocks main thread for up to 2s on quit to refresh process cache (intentional, documented), fires async on autosave. Pattern is correct and well-commented.
Sources/TabManager.swift Adds allTerminalTTYNames() helper that flattens TTY names across all workspaces for cache refresh. Small, clean change.
Sources/Panels/TerminalPanel.swift Propagates initialInput parameter through TerminalPanel convenience init. Trivial pass-through change.
cmuxTests/SessionPersistenceTests.swift Comprehensive test suite covering enabled/disabled toggle, pattern matching, denylist cases (credentials, destructive commands, system integrity), and edge cases. Good coverage of the new functionality.

Sequence Diagram

sequenceDiagram
    participant App as AppDelegate
    participant Cache as SessionForegroundProcessCache
    participant Detector as SessionForegroundProcessDetector
    participant Workspace as Workspace
    participant Terminal as TerminalSurface

    Note over App: On quit (isTerminatingApp)
    App->>Cache: refreshSync(ttyNames:, timeout: 2s)
    Cache->>Detector: detect(forTTY:) × N
    Detector->>Detector: ps -t ttyName → processSnapshots
    Detector->>Detector: KERN_PROCARGS2 → commandLineString(pid)
    Detector-->>Cache: ForegroundProcess (if allowlisted)
    Cache-->>App: cache updated (or timeout → use old cache)

    App->>Workspace: buildSessionSnapshot()
    Workspace->>Cache: cachedCommandLine(forTTY:)
    Cache-->>Workspace: detectedCommand (String?)
    Workspace-->>App: SessionTerminalPanelSnapshot(detectedCommand:, restoreCommand:)

    Note over App: On restore
    App->>Workspace: restoreFromSnapshot()
    Workspace->>Workspace: commandToRestore = isEnabled ? restoreCommand ?? detectedCommand : nil
    Workspace->>Workspace: safeInitialInput = remoteStartupCmd != nil ? nil : commandToRestore
    Workspace->>Terminal: newTerminalSurface(initialInput: safeInitialInput)
    Terminal->>Terminal: initialInput + "\n" → Ghostty surface on first create
    Terminal->>Terminal: initialInput = nil (cleared after use)
Loading

Reviews (5): Last reviewed commit: "Block commands with sensitive credential..." | Re-trigger Greptile

Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/SessionPersistence.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
cmuxTests/SessionPersistenceTests.swift (1)

1196-1203: Consider adding a test that verifies destructive commands aren't matched by default patterns.

The current test verifies that literal patterns like "rm *" aren't in defaultAllowlistPatterns, but doesn't verify that isCommandAllowed("rm -rf /", rawAllowlist: defaultRawAllowlist) returns false. If a future change added an overly broad pattern, this test wouldn't catch it.

💡 Suggested additional test
func testDefaultAllowlistDoesNotMatchDestructiveCommands() {
    let defaultRaw = SessionRestoreCommandSettings.defaultAllowlistPatterns.joined(separator: "\n")
    XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("rm -rf /", rawAllowlist: defaultRaw))
    XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("sudo rm -rf /", rawAllowlist: defaultRaw))
    XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("git push --force", rawAllowlist: defaultRaw))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1196 - 1203, Add a new
unit test that asserts the default allowlist patterns do not match destructive
commands: use SessionRestoreCommandSettings.defaultAllowlistPatterns joined into
a rawAllowlist string and call
SessionRestoreCommandSettings.isCommandAllowed(...) with inputs like "rm -rf /",
"sudo rm -rf /", and "git push --force", asserting each returns false; place
this alongside the existing testDefaultAllowlistExcludesDestructiveCommands
(e.g., name it testDefaultAllowlistDoesNotMatchDestructiveCommands) so the suite
covers both absence of literal patterns and actual matching behaviour.
Sources/SessionPersistence.swift (1)

546-546: Empty allowlist falls back to defaults instead of disabling all restores.

If a user intentionally clears the allowlist (e.g., sets it to whitespace or all comments) to disable command restoration entirely, the current logic falls back to defaultAllowlistPatterns. This may not match user intent.

Consider whether an empty parsed result should mean "allow nothing" (return []) rather than "use defaults". If you want to preserve the current behavior, document it clearly in part 2's Settings UI.

♻️ Alternative: respect empty allowlist as "allow nothing"
     static func normalizedAllowlistPatterns(rawValue: String?) -> [String] {
         let source: String
         if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
             source = rawValue
         } else {
             source = defaultAllowlistText
         }
         let parsed = parsePatterns(from: source)
-        return parsed.isEmpty ? defaultAllowlistPatterns : parsed
+        // If user provided a rawValue but it parsed to empty, respect that as "allow nothing"
+        if rawValue != nil && parsed.isEmpty {
+            return []
+        }
+        return parsed.isEmpty ? defaultAllowlistPatterns : parsed
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SessionPersistence.swift` at line 546, The current return uses
defaultAllowlistPatterns when parsed is empty which overrides an intentional
empty allowlist; change the logic in SessionPersistence (the code returning
parsed.isEmpty ? defaultAllowlistPatterns : parsed) so that an explicitly-empty
parsed result is respected (return parsed or return [] when parsed.isEmpty) and
only fall back to defaultAllowlistPatterns when parsing failed/parsed is nil;
update any related docstring/comment in the function and adjust tests/UI for the
Settings allowlist behavior to reflect that an empty allowlist disables
restores.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3890-3893: The DEBUG log currently prints full startup command
content via dlog using resolvedInitialInput, initialInput, and
baseConfig.initialInput which can leak sensitive data and break log framing;
update the logging in the GhosttyTerminalView startup path so dlog only emits a
redacted summary (e.g., presence flags and byte/line counts or a short hash) for
resolvedInitialInput/initialInput/baseConfig.initialInput and include the
surface id (id.uuidString.prefix(5)) and which inputs are present rather than
their full debugDescription, ensuring multiline input is not printed.
- Around line 3882-3889: The restored initial input can be replayed on
subsequent surface recreations; make initialInput mutable on TerminalSurface
(and allow baseConfig.initialInput to be overridden) and consume it after the
first successful surface creation inside createSurface(for:): after calling
ghostty_surface_new(...) clear/set TerminalSurface.initialInput = nil (and if
you use baseConfig.initialInput, ensure you also set that source to nil or
prefer the mutable per-surface initialInput) so the restore command is only
applied once.

In `@Sources/SessionPersistence.swift`:
- Around line 653-679: processSnapshots(forTTY:) blocks the main thread by
calling process.run() + waitUntilExit(); change it to perform the ps invocation
off the main queue (e.g., DispatchQueue.global().async) and use a
terminationHandler or readDataToEndOfFileInBackground + a completion callback
(or make processSnapshots async/throwing) so callers like sessionPanelSnapshot,
sessionSnapshot, buildSessionSnapshot and saveSessionSnapshot do not block UI
during autosave/quit; alternatively, if you cannot change call sites, document
and assert that detect(forTTY:) / processSnapshots(forTTY:) must only be called
from a background queue and add an assertion to catch accidental main‑thread
usage.

In `@Sources/Workspace.swift`:
- Around line 5570-5571: The new per-panel state panelRestoreCommands is not
included in the panel-metadata lifecycle causing it to be lost on moves and
orphaned on prune; update the DetachedSurfaceTransfer type (or wherever surface
metadata is packaged) to carry panelRestoreCommands for transferred panels and
ensure pruneSurfaceMetadata includes panelRestoreCommands when deciding which
per-panel entries to keep or remove (also update any remapping logic that
translates old panel UUIDs to new ones so panelRestoreCommands keys are remapped
alongside other metadata).
- Around line 446-450: sessionSnapshot() in Workspace (which is `@MainActor`) is
calling SessionForegroundProcessDetector.detect(forTTY:) synchronously (via
detectedCommand) causing /bin/ps to run on the main actor and stall UI; instead,
run detection off the main actor and read a cached value here. Implement an
async/background cache for SessionForegroundProcessDetector results (e.g. a
detached Task, an actor or a background queue updater) keyed by
surfaceTTYNames[panelId] and expose a synchronous cached property or method
(e.g. foregroundCommandCache[tty] or
SessionForegroundProcessDetectorCache.getCachedCommand(forTTY:)) that
sessionSnapshot() can read without blocking; update the cache periodically or on
relevant events and replace the direct call to
SessionForegroundProcessDetector.detect(forTTY:) in Workspace.sessionSnapshot()
with a read from that cache (use panelId and surfaceTTYNames to form the key).

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1196-1203: Add a new unit test that asserts the default allowlist
patterns do not match destructive commands: use
SessionRestoreCommandSettings.defaultAllowlistPatterns joined into a
rawAllowlist string and call SessionRestoreCommandSettings.isCommandAllowed(...)
with inputs like "rm -rf /", "sudo rm -rf /", and "git push --force", asserting
each returns false; place this alongside the existing
testDefaultAllowlistExcludesDestructiveCommands (e.g., name it
testDefaultAllowlistDoesNotMatchDestructiveCommands) so the suite covers both
absence of literal patterns and actual matching behaviour.

In `@Sources/SessionPersistence.swift`:
- Line 546: The current return uses defaultAllowlistPatterns when parsed is
empty which overrides an intentional empty allowlist; change the logic in
SessionPersistence (the code returning parsed.isEmpty ? defaultAllowlistPatterns
: parsed) so that an explicitly-empty parsed result is respected (return parsed
or return [] when parsed.isEmpty) and only fall back to defaultAllowlistPatterns
when parsing failed/parsed is nil; update any related docstring/comment in the
function and adjust tests/UI for the Settings allowlist behavior to reflect that
an empty allowlist disables restores.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f34d8278-5d40-498c-b49a-bcbee7632b91

📥 Commits

Reviewing files that changed from the base of the PR and between 26ffcf1 and 29f5628.

📒 Files selected for processing (5)
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/SessionPersistence.swift
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/SessionPersistence.swift">

<violation number="1" location="Sources/SessionPersistence.swift:522">
P1: `"watch *"` is overly broad for the default allowlist because it permits restoring arbitrary subcommands, including destructive ones.</violation>

<violation number="2" location="Sources/SessionPersistence.swift:737">
P2: Command reconstruction treats raw bytes as UnicodeScalars and joins argv with spaces, corrupting UTF‑8 arguments and losing argument boundaries/escaping during auto-restore.</violation>

<violation number="3" location="Sources/SessionPersistence.swift:745">
P2: Reconstructing argv with a plain space join loses quoting, so arguments that contain spaces will be split into different parameters when the command is restored.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:5571">
P2: panelRestoreCommands is stored per panel but never transferred in DetachedSurfaceTransfer, so detaching/reattaching a terminal panel drops the explicit restore command.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/Workspace.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 3712-3714: saveSessionSnapshot is `@MainActor` but calling
SessionForegroundProcessCache.refresh(ttyNames:) synchronously causes per‑TTY ps
scans on the UI thread; move the refresh off the main actor. Change the call to
run on a background queue (e.g. DispatchQueue.global()/Task.detached) and await
its completion if the snapshot depends on the results, or run it fire-and-forget
if not required for the immediate snapshot, so refreshForegroundProcessCache()
executes off the main thread instead of inline in saveSessionSnapshot.

In `@Sources/Workspace.swift`:
- Around line 660-665: When restoring a terminal, avoid passing initialInput
when the startup command is an SSH invocation: detect if
remoteTerminalStartupCommand() is non-nil and skip supplying commandToRestore to
newTerminalSurface’s initialInput parameter; instead call newTerminalSurface
with the same arguments but omit or pass nil for initialInput (locations to
update include the newTerminalSurface invocation around
paneId/workingDirectory/replayEnvironment/commandToRestore and the analogous
block around the other occurrence referenced at lines ~7716-7740). Ensure
initialCommand/initialInput logic distinguishes local restores from remote SSH
startups so the restore text is not sent to the ssh process.
- Around line 645-652: The code currently reads
snapshot.terminal?.restoreCommand and may persist the raw value into
panelRestoreCommands even when disallowed; change the logic so you only ever
write the trimmed, allowlisted command into panelRestoreCommands via
SessionRestoreCommandSettings (or skip writing entirely) — i.e., trim the value,
check SessionRestoreCommandSettings.isCommandAllowed(trimmed) and only
assign/save trimmed when allowed, otherwise do not copy the snapshot value into
panelRestoreCommands (and ensure the same fix is applied to the other occurrence
that uses snapshot.terminal?.restoreCommand).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 325763af-9fa5-473d-a984-b2e7b60b7115

📥 Commits

Reviewing files that changed from the base of the PR and between 29f5628 and f887189.

📒 Files selected for processing (6)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift
✅ Files skipped from review due to trivial changes (1)
  • cmuxTests/SessionPersistenceTests.swift
🚧 Files skipped from review as they are similar to previous changes (2)
  • Sources/GhosttyTerminalView.swift
  • Sources/SessionPersistence.swift

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/SessionPersistenceTests.swift">

<violation number="1" location="cmuxTests/SessionPersistenceTests.swift:1207">
P2: `testDenylistBlocksSystemCommands` does not isolate denylist behavior because it uses the default allowlist, so it can pass from allowlist rejection even if denylist rules regress.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread cmuxTests/SessionPersistenceTests.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
Sources/AppDelegate.swift (1)

3712-3713: ⚠️ Potential issue | 🟠 Major

group.wait() keeps snapshot saves UI-blocking.

saveSessionSnapshot still runs on @MainActor, and refreshForegroundProcessCacheSync() waits synchronously for SessionForegroundProcessCache.refresh(...), which is itself a blocking per-TTY ps loop. Autosave, resign-active, and quit-time saves can still stall the app until the full scan finishes. Please either refresh ahead of time and consume the last completed cache here, or move the refresh+save pipeline off the main actor.

Also applies to: 3988-4000

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3712 - 3713, The call to
refreshForegroundProcessCacheSync() blocks the main thread because
saveSessionSnapshot remains on `@MainActor` while
SessionForegroundProcessCache.refresh(...) does a per-TTY ps loop; change the
flow so the snapshot save does not wait for that blocking refresh: either (A)
pre-run the refresh on a background queue and have
refreshForegroundProcessCacheSync() read and return the last completed cache
(consume cache snapshot instead of running refresh synchronously) or (B) move
the refresh+save pipeline off the main actor entirely by performing
SessionForegroundProcessCache.refresh(...) on a background Task/DispatchQueue
and then call saveSessionSnapshot only after that background work completes (or
call a non-@MainActor helper that collects data then dispatches the minimal
UI-bound work back to `@MainActor`). Update refreshForegroundProcessCacheSync,
SessionForegroundProcessCache.refresh, and saveSessionSnapshot call sites to use
the chosen approach so the UI is not blocked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 235-242: allTerminalTTYNames() currently returns TTYs from
surfaceTTYNames which may be rehydrated from snapshots (see
applySessionPanelMetadata), causing stale TTYs to be used; change the logic to
only include TTY names that come from a live surface (e.g., check the panel
instance for a "hasLiveTTY"/"isLive" flag or only read TTYs that the
TerminalPanel reports as live) or clear/stage restored entries when
applySessionPanelMetadata populates surfaceTTYNames so they are not returned
until the terminal reports a live TTY; update allTerminalTTYNames(),
applySessionPanelMetadata(), and surfaceTTYNames handling accordingly so
snapshot-restored TTYs are excluded until confirmed live.
- Around line 6361-6371: The panel restore command is being stored into
panelRestoreCommands without being validated (sessionPanelSnapshot writes that
dictionary verbatim and the reattach path bypasses setPanelRestoreCommand), so
update setPanelRestoreCommand to call
SessionRestoreCommandSettings.isCommandAllowed(trimmed) before storing and
reject/discard disallowed commands; also change the reattachment/reattach
codepath to route through the same helper (setPanelRestoreCommand) or the same
validation helper so all writes to panelRestoreCommands are consistently
gate-checked and use the same trimming/allowlist logic.

---

Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 3712-3713: The call to refreshForegroundProcessCacheSync() blocks
the main thread because saveSessionSnapshot remains on `@MainActor` while
SessionForegroundProcessCache.refresh(...) does a per-TTY ps loop; change the
flow so the snapshot save does not wait for that blocking refresh: either (A)
pre-run the refresh on a background queue and have
refreshForegroundProcessCacheSync() read and return the last completed cache
(consume cache snapshot instead of running refresh synchronously) or (B) move
the refresh+save pipeline off the main actor entirely by performing
SessionForegroundProcessCache.refresh(...) on a background Task/DispatchQueue
and then call saveSessionSnapshot only after that background work completes (or
call a non-@MainActor helper that collects data then dispatches the minimal
UI-bound work back to `@MainActor`). Update refreshForegroundProcessCacheSync,
SessionForegroundProcessCache.refresh, and saveSessionSnapshot call sites to use
the chosen approach so the UI is not blocked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47fb34d5-a7bd-41c8-9470-2003f199b637

📥 Commits

Reviewing files that changed from the base of the PR and between f887189 and a81b133.

📒 Files selected for processing (4)
  • Sources/AppDelegate.swift
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 2, 2026

@greptile-apps @cubic-dev-ai Please re-review - all flagged issues have been addressed in commits a81b133 and 80ed5c3.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 2, 2026

@greptile-apps @cubic-dev-ai Please re-review - all flagged issues have been addressed in commits a81b133 and 80ed5c3.

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
Sources/SessionPersistence.swift (1)

812-819: Consider extracting shell names to a static constant.

The inline list works correctly, but extracting it as a static property would improve readability and make future additions easier to manage.

♻️ Optional refactor
 enum SessionForegroundProcessDetector {
     private static let psPath = "/bin/ps"
+    
+    /// Shell executables to skip when detecting foreground commands.
+    private static let shellExecutables: Set<String> = [
+        "zsh", "bash", "sh", "fish", "tcsh", "ksh", "dash",  // POSIX-ish shells
+        "csh",                                               // C shell
+        "pwsh", "powershell",                                // PowerShell
+        "nu", "nushell",                                     // Nushell
+        "elvish", "xonsh", "oil", "osh",                     // Alternative shells
+        "rc", "es",                                          // Plan 9 shells
+    ]
 
     // ... in detect(forTTY:) ...
-        let shellNames = [
-            "zsh", "bash", "sh", "fish", "tcsh", "ksh", "dash",
-            // ...
-        ]
-        if shellNames.contains(foreground.executableName) {
+        if shellExecutables.contains(foreground.executableName) {
             return nil
         }

Using a Set also gives O(1) lookup instead of O(n), though with ~15 elements the difference is negligible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SessionPersistence.swift` around lines 812 - 819, Extract the inline
shellNames array into a shared static constant on the SessionPersistence type
(e.g. static let knownShells: Set<String> = [...]) and replace the local let
shellNames usage with SessionPersistence.knownShells (or Self.knownShells inside
instance methods); convert the collection to a Set for O(1) lookup and keep the
same elements (zsh, bash, sh, fish, tcsh, ksh, dash, csh, pwsh, powershell, nu,
nushell, elvish, xonsh, oil, osh, rc, es) to preserve behavior and simplify
future maintenance.
Sources/Workspace.swift (1)

669-675: Reuse setPanelRestoreCommand here too.

This reimplements the same trim/allowlist policy that already lives in setPanelRestoreCommand, so the restore path can drift from the socket and reattach paths.

♻️ Suggested simplification
-            // Persist explicit restoreCommand if allowed (blocked commands don't become sticky)
-            if let restoreCommand = snapshot.terminal?.restoreCommand, !restoreCommand.isEmpty {
-                let trimmed = restoreCommand.trimmingCharacters(in: .whitespacesAndNewlines)
-                if SessionRestoreCommandSettings.isCommandAllowed(trimmed) {
-                    setPanelRestoreCommand(panelId: terminalPanel.id, command: trimmed)
-                }
-            }
+            // Persist explicit restoreCommand if allowed (blocked commands don't become sticky)
+            setPanelRestoreCommand(panelId: terminalPanel.id, command: snapshot.terminal?.restoreCommand)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 669 - 675, The code duplicates the
trim/allowlist logic already implemented in setPanelRestoreCommand; remove the
manual trimming and SessionRestoreCommandSettings check and simply call
setPanelRestoreCommand(panelId: terminalPanel.id, command: restoreCommand) when
snapshot.terminal?.restoreCommand is non-nil/non-empty so the same normalization
and allowlist enforcement is used consistently for restore, socket, and reattach
paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 455-464: When creating the SessionTerminalPanelSnapshot, avoid
persisting panelRestoreCommands for terminals backed by a remote startup
command: compute a safeRestore = (remoteTerminalStartupCommand() == nil) ?
panelRestoreCommands[panelId] : nil and pass that into
SessionTerminalPanelSnapshot's restoreCommand instead of
panelRestoreCommands[panelId], so remote-only overrides are not replayed
locally; update the terminalSnapshot construction (symbols: terminalSnapshot,
SessionTerminalPanelSnapshot, panelRestoreCommands,
remoteTerminalStartupCommand()) accordingly.

---

Nitpick comments:
In `@Sources/SessionPersistence.swift`:
- Around line 812-819: Extract the inline shellNames array into a shared static
constant on the SessionPersistence type (e.g. static let knownShells:
Set<String> = [...]) and replace the local let shellNames usage with
SessionPersistence.knownShells (or Self.knownShells inside instance methods);
convert the collection to a Set for O(1) lookup and keep the same elements (zsh,
bash, sh, fish, tcsh, ksh, dash, csh, pwsh, powershell, nu, nushell, elvish,
xonsh, oil, osh, rc, es) to preserve behavior and simplify future maintenance.

In `@Sources/Workspace.swift`:
- Around line 669-675: The code duplicates the trim/allowlist logic already
implemented in setPanelRestoreCommand; remove the manual trimming and
SessionRestoreCommandSettings check and simply call
setPanelRestoreCommand(panelId: terminalPanel.id, command: restoreCommand) when
snapshot.terminal?.restoreCommand is non-nil/non-empty so the same normalization
and allowlist enforcement is used consistently for restore, socket, and reattach
paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f060831e-99a6-4d0d-a624-1475a4a28cab

📥 Commits

Reviewing files that changed from the base of the PR and between a81b133 and 80ed5c3.

📒 Files selected for processing (3)
  • Sources/AppDelegate.swift
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/AppDelegate.swift

Comment thread Sources/Workspace.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/Workspace.swift (1)

237-241: Skip remote-backed terminals from the cache refresh set.

sessionPanelSnapshot(...) now ignores cached foreground detections for remote-backed terminals, so collecting their TTYs here only adds extra ps work during the pre-snapshot refresh. Reusing the same remote-backed predicate here would trim unnecessary quit-time work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 237 - 241, In allTerminalTTYNames(),
filter out terminal panels that are remote-backed the same way
sessionPanelSnapshot(...) does so we don't include their TTYs in
surfaceTTYNames; update the compactMap closure (which currently checks "panel is
TerminalPanel") to also check the existing remote-backed predicate (reuse the
same method/property used by sessionPanelSnapshot, e.g. panel.isRemoteBacked or
the project's remote-backed check) and return nil for remote-backed terminals so
only local terminal TTYs from surfaceTTYNames[panelId] are collected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 455-469: The snapshot captures listeningPorts even for
remote-backed panels; reuse the existing remote-panel predicate
(isRemoteBackedTerminal computed from activeRemoteTerminalSurfaceIds and
transferredRemoteCleanupConfigurationsByPanelId) and ensure
SessionTerminalPanelSnapshot is created with listeningPorts set to [] when
isRemoteBackedTerminal is true (analogous to how restoreCommand and
detectedCommand are gated), and update applySessionPanelMetadata to ignore/skip
restoring any listeningPorts for panels that are remote-backed so
remote-detected ports are never persisted or rehydrated.
- Around line 649-680: The code persists an incoming
snapshot.terminal?.restoreCommand into panel restore state even for
SSH/remote-backed terminals; avoid making remote commands sticky. Change the
final block (the guard that calls setPanelRestoreCommand) to only persist the
trimmed restoreCommand when it's allowed AND the panel/terminal is local (e.g.,
check a local/remote indicator exposed by the created terminalPanel or
snapshot.terminal such as terminalPanel.isRemote/terminalPanel.connectionType or
snapshot.terminal?.isRemote/?.backend != .ssh) so that
setPanelRestoreCommand(panelId: terminalPanel.id, command: trimmed) is skipped
for remote-backed terminals.

---

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 237-241: In allTerminalTTYNames(), filter out terminal panels that
are remote-backed the same way sessionPanelSnapshot(...) does so we don't
include their TTYs in surfaceTTYNames; update the compactMap closure (which
currently checks "panel is TerminalPanel") to also check the existing
remote-backed predicate (reuse the same method/property used by
sessionPanelSnapshot, e.g. panel.isRemoteBacked or the project's remote-backed
check) and return nil for remote-backed terminals so only local terminal TTYs
from surfaceTTYNames[panelId] are collected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0d207f6-6237-47c1-bf94-2b89668ad7bf

📥 Commits

Reviewing files that changed from the base of the PR and between 80ed5c3 and 732f845.

📒 Files selected for processing (1)
  • Sources/Workspace.swift

Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 3, 2026

@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - latest commit 1561fd8 addresses all remaining feedback:

  • Removed cargo watch * from allowlist (same -x subcommand risk as watch *)
  • Fixed misleading test comments about * * pattern
  • All prior issues (listeningPorts gating, remote restoreCommand sticky, TTY cache optimization) confirmed fixed

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 3, 2026

@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - latest commit 1561fd8 addresses all remaining feedback:

  • Removed cargo watch * from allowlist (same -x subcommand risk as watch *)
  • Fixed misleading test comments about * * pattern
  • All prior issues (listeningPorts gating, remote restoreCommand sticky, TTY cache optimization) confirmed fixed

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

@mrosnerr, thanks for the update! I'll kick off a fresh review of the latest commit 1561fd8 now, covering the allowlist cleanup, test comment fixes, and all previously flagged issues.


🧠 Learnings used
Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:8178-8197
Timestamp: 2026-04-01T22:46:08.900Z
Learning: Repo: manaflow-ai/cmux — Do not persist remote-detected listening ports. In Sources/Workspace.swift, sessionPanelSnapshot must emit [] for listeningPorts when a panel is remote (panelId ∈ remoteDetectedSurfaceIds or isRemoteTerminalSurface), and applySessionPanelMetadata must not restore remote ports. Remote detections remain in-memory only; covered by SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts.
✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/SessionPersistence.swift">

<violation number="1" location="Sources/SessionPersistence.swift:935">
P1: argv parsing undercounts non-UTF8 entries, so command extraction can scan beyond argv and persist unintended process-args data.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/SessionPersistence.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
Sources/Workspace.swift (1)

749-753: ⚠️ Potential issue | 🟠 Major

Skip restoring listeningPorts for remote-backed panels too.

Line 751 correctly stops rehydrating dead-session TTYs, but Line 749 still restores snapshot.listeningPorts unconditionally. Please reuse the same remote-backed predicate here; otherwise a legacy or tampered snapshot can repopulate remote-only ports after restore.

🛡️ Suggested fix
-        surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted()
+        let isRemoteBackedTerminal =
+            activeRemoteTerminalSurfaceIds.contains(panelId) ||
+            transferredRemoteCleanupConfigurationsByPanelId[panelId] != nil
+        surfaceListeningPorts[panelId] = isRemoteBackedTerminal
+            ? []
+            : Array(Set(snapshot.listeningPorts)).sorted()

Based on learnings: remote-detected listening ports must never be persisted, and applySessionPanelMetadata must not restore any remote listening ports during session rehydration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 749 - 753, In
applySessionPanelMetadata, don't unconditionally restore snapshot.listeningPorts
into surfaceListeningPorts; wrap the assignment of
surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() in
the same "remote-backed" check used to skip restoring ttyName so that
remote-backed panels never get listeningPorts reapplied from a snapshot—only
restore snapshot.listeningPorts for non-remote-backed panels.
Sources/SessionPersistence.swift (1)

754-770: ⚠️ Potential issue | 🟠 Major

refreshSync still blocks the quit path.

Sources/AppDelegate.swift calls the synchronous variant from applicationShouldTerminate(...) before saving the session. That means quit still waits on every ps + sysctl pass here, so shutdown can stall under load or with many TTYs. I’d avoid the sync refresh on termination and fall back to the last async cache snapshot, or add a bounded timeout/fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SessionPersistence.swift` around lines 754 - 770, The synchronous
refreshSync(ttyNames:) currently blocks shutdown; change the termination path to
avoid calling it: in applicationShouldTerminate(...) stop invoking refreshSync
and instead read the most recent async snapshot from cache (protected by lock)
or implement a bounded-sync variant that dispatches the same work onto queue
asynchronously and waits with a timeout (e.g. DispatchSemaphore/DispatchGroup
wait(timeout:)), then falls back to the existing cache if the wait times out.
Update references to queue, cache, and refreshSync to use the new non-blocking
fallback so quitting does not stall on long ps/sysctl calls.
🧹 Nitpick comments (2)
Sources/AppDelegate.swift (1)

3994-4005: Deduplicate TTY names before refresh to avoid redundant scans.

Line 3994 and Line 4002 can pass duplicate TTYs if contexts overlap; deduping reduces unnecessary ps work with no behavior change.

♻️ Proposed refactor
 private func refreshForegroundProcessCacheAsync() {
-    let allTTYNames = mainWindowContexts.values.flatMap { context in
+    let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
         context.tabManager.allTerminalTTYNames()
-    }
-    SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames))
+    })
+    SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames))
 }
 
 private func refreshForegroundProcessCacheSync() {
-    let allTTYNames = mainWindowContexts.values.flatMap { context in
+    let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
         context.tabManager.allTerminalTTYNames()
-    }
-    SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames))
+    })
+    SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3994 - 4005, The code gathers TTY
names with mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames() } and then calls
SessionForegroundProcessCache.shared.refresh(...) and refreshSync(...);
deduplicate the collected TTY names before calling refresh/refreshSync (e.g.,
convert to a Set then back to Array) so duplicate TTYs from overlapping contexts
are removed and redundant ps scanning is avoided. Ensure you update both call
sites that use allTTYNames and keep the same types expected by refresh and
refreshSync.
cmuxTests/SessionPersistenceTests.swift (1)

1219-1305: Consider table-driven denylist cases to reduce repetition.

Most denylist tests follow the same arrange/assert shape; a parameterized helper would keep the same coverage with lower maintenance overhead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1219 - 1305, Refactor
the repetitive denylist tests by introducing a table-driven helper (e.g., a
private helper method in the test file) that calls
SessionRestoreCommandSettings.isCommandAllowed for a given rawAllowlist and an
array of commands and asserts they are all false; replace repeated patterns in
testDenylistBlocksDestructiveCommandsEvenIfInAllowlist,
testDenylistIsCaseInsensitive, testDenylistBlocksExactCommands,
testDenylistBlocksSystemCommands, testDenylistBlocksRemoteCodeExecution,
testDenylistBlocksHistoryReplay, testDenylistBlocksCrontabDestruction,
testDenylistBlocksMacOSSystemIntegrity,
testDenylistBlocksContainerMassDestruction,
testDenylistBlocksNetworkDestruction, and testDenylistBlocksLaunchctlDestruction
with calls to that helper to keep coverage but reduce duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3158-3159: The current assignment of self.initialInput from
trimmedInput allows embedded newlines (e.g. "ssh host\nrm -rf ~"); update the
logic that sets initialInput (the trimmedInput → self.initialInput block) to
reject any input containing newline characters (check for "\n" and "\r") and set
self.initialInput = nil if found, rather than accepting or trimming them; apply
the same newline-rejection guard before any code that restores or submits
initialInput (the restore path around the code near lines 3885–3887) so
multi-line inputs are never accepted or re-submitted.

In `@Sources/SessionPersistence.swift`:
- Around line 668-670: The isCommandAllowed(_ command: String, defaults:
UserDefaults = .standard) helper currently ignores the user toggle; modify it to
first read the sessionRestoreCommandsEnabled Bool from defaults and immediately
return false when that flag is false, otherwise proceed to call
isCommandAllowed(command, rawAllowlist: defaults.string(forKey: allowlistKey));
reference the symbol sessionRestoreCommandsEnabled and the existing overload
isCommandAllowed(command, rawAllowlist:) so the gating happens before any
allowlist checks or writing to session JSON.

---

Duplicate comments:
In `@Sources/SessionPersistence.swift`:
- Around line 754-770: The synchronous refreshSync(ttyNames:) currently blocks
shutdown; change the termination path to avoid calling it: in
applicationShouldTerminate(...) stop invoking refreshSync and instead read the
most recent async snapshot from cache (protected by lock) or implement a
bounded-sync variant that dispatches the same work onto queue asynchronously and
waits with a timeout (e.g. DispatchSemaphore/DispatchGroup wait(timeout:)), then
falls back to the existing cache if the wait times out. Update references to
queue, cache, and refreshSync to use the new non-blocking fallback so quitting
does not stall on long ps/sysctl calls.

In `@Sources/Workspace.swift`:
- Around line 749-753: In applySessionPanelMetadata, don't unconditionally
restore snapshot.listeningPorts into surfaceListeningPorts; wrap the assignment
of surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted()
in the same "remote-backed" check used to skip restoring ttyName so that
remote-backed panels never get listeningPorts reapplied from a snapshot—only
restore snapshot.listeningPorts for non-remote-backed panels.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1219-1305: Refactor the repetitive denylist tests by introducing a
table-driven helper (e.g., a private helper method in the test file) that calls
SessionRestoreCommandSettings.isCommandAllowed for a given rawAllowlist and an
array of commands and asserts they are all false; replace repeated patterns in
testDenylistBlocksDestructiveCommandsEvenIfInAllowlist,
testDenylistIsCaseInsensitive, testDenylistBlocksExactCommands,
testDenylistBlocksSystemCommands, testDenylistBlocksRemoteCodeExecution,
testDenylistBlocksHistoryReplay, testDenylistBlocksCrontabDestruction,
testDenylistBlocksMacOSSystemIntegrity,
testDenylistBlocksContainerMassDestruction,
testDenylistBlocksNetworkDestruction, and testDenylistBlocksLaunchctlDestruction
with calls to that helper to keep coverage but reduce duplication.

In `@Sources/AppDelegate.swift`:
- Around line 3994-4005: The code gathers TTY names with
mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames() } and then calls
SessionForegroundProcessCache.shared.refresh(...) and refreshSync(...);
deduplicate the collected TTY names before calling refresh/refreshSync (e.g.,
convert to a Set then back to Array) so duplicate TTYs from overlapping contexts
are removed and redundant ps scanning is avoided. Ensure you update both call
sites that use allTTYNames and keep the same types expected by refresh and
refreshSync.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 223c37bb-42e4-4b1e-bff4-e6527c8e7435

📥 Commits

Reviewing files that changed from the base of the PR and between 26ffcf1 and 1561fd8.

📒 Files selected for processing (7)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment thread Sources/SessionPersistence.swift
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 3, 2026

@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - commit ac5665d addresses all feedback from the previous review:

Actionable fixes:

  • Reject initialInput containing newlines (prevents command injection like ssh host\nrm -rf ~)
  • Check sessionRestoreCommandsEnabled toggle before allowlist validation
  • Skip restoring listeningPorts for remote-backed panels
  • Add 2s timeout to refreshSync to prevent quit from stalling
  • Fix argv parsing to count non-UTF8 entries (prevents scanning past argv into env vars)

Nitpick fixes:

  • Dedupe TTY names before cache refresh
  • Refactor denylist tests to table-driven format

All prior duplicate comments about remote-backed panels and refreshSync blocking have been addressed.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 3, 2026

@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - commit ac5665d addresses all feedback from the previous review:

Actionable fixes:

  • Reject initialInput containing newlines (prevents command injection like ssh host\nrm -rf ~)
  • Check sessionRestoreCommandsEnabled toggle before allowlist validation
    ...

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

@mrosnerr, thanks for the detailed summary! Kicking off a fresh review of commit ac5665d now. Here's a quick acknowledgment of the fixes:

Actionable fixes confirmed:

  • ✅ Newline rejection in initialInput — closes the ssh host\nrm -rf ~ injection vector
  • sessionRestoreCommandsEnabled toggle checked before allowlist validation
  • listeningPorts skipped for remote-backed panels on restore
  • ✅ 2s timeout added to refreshSync — prevents blocking app quit
  • ✅ Argv parsing fixed to correctly count non-UTF8 entries — prevents scanning into env vars

Nitpick fixes confirmed:

  • ✅ TTY names deduplicated before cache refresh
  • ✅ Denylist tests refactored to table-driven format

Starting the full review now.


🧠 Learnings used
Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:8178-8197
Timestamp: 2026-04-01T22:46:08.900Z
Learning: Repo: manaflow-ai/cmux — Do not persist remote-detected listening ports. In Sources/Workspace.swift, sessionPanelSnapshot must emit [] for listeningPorts when a panel is remote (panelId ∈ remoteDetectedSurfaceIds or isRemoteTerminalSurface), and applySessionPanelMetadata must not restore remote ports. Remote detections remain in-memory only; covered by SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:4036-4038
Timestamp: 2026-04-01T09:50:41.471Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift (buildInteractiveRemoteShellScript), remote shells intentionally export CMUX_SOCKET_PATH and CMUX_SOCKET as "127.0.0.1:<relayPort>" and prepend PATH with "$HOME/.cmux/bin"; CMUX_BUNDLED_CLI_PATH points to that wrapper. WorkspaceRemoteSessionController.remoteCLIWrapperScript() detects host:port, reads "~/.cmux/relay/<port>.daemon_path", and delegates to cmuxd-remote. Do not flag the non-UNIX value of CMUX_SOCKET_PATH/CMUX_SOCKET as a bug; it is the relay contract for remote sessions.

Learnt from: MaTriXy
Repo: manaflow-ai/cmux PR: 1460
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-16T08:02:13.558Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, socket v2 methods that accept surface_id/panel_id must handle cross-window routing. For panel background state mutations, v2PanelMarkBackground(params:) and v2PanelMarkForeground(params:) first try v2ResolveTabManager(params:), but only accept it if that manager actually owns the panelId; otherwise they fall back to AppDelegate.shared?.locateSurface(surfaceId:) to find the correct TabManager across windows. This pattern should be used for any panel_id-only routes to avoid active-window bias.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:3891-3903
Timestamp: 2026-04-02T21:37:22.443Z
Learning: Repo manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift (and similar Swift files), never log raw startup commands or initialInput in DEBUG. When diagnosing, log only presence flags and byte counts (and include surface id) to avoid leaking sensitive paths/tokens and multiline content in logs.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-01T09:50:23.728Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift within CMUXCLI.buildInteractiveRemoteShellScript(...), never export CMUX_TAB_ID from the workspace UUID. CMUX_TAB_ID must be surface-scoped: only set it when a surface ID is available (map CMUX_TAB_ID to CMUX_SURFACE_ID). Rationale: tab-action/rename-tab resolve CMUX_TAB_ID before CMUX_SURFACE_ID; workspace-scoped values misroute or fail.

Learnt from: solssak
Repo: manaflow-ai/cmux PR: 2397
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-31T02:33:40.981Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, prefer ghostty_surface_text (via writePTYText) for large socket text payloads; do not manually wrap with \e[200~/\e[201~. Ghostty inserts bracketed-paste markers itself when mode 2004 is enabled. Threshold is pasteTextThreshold = 256; control scalars still go through handleControlScalar and short text through sendTextEvent.

Learnt from: apollow
Repo: manaflow-ai/cmux PR: 1089
File: CLI/cmux.swift:462-499
Timestamp: 2026-03-09T02:08:54.956Z
Learning: Repo: manaflow-ai/cmux
PR: `#1089`
File: CLI/cmux.swift
Component: ClaudeHookTagExtractor.extractTags(subtitle:body:)
Learning: For Claude Code session tag extraction, pre-redact sensitive spans (UUIDs, emails, access tokens, filesystem paths, ENV_VAR=..., long numerics) across the combined body+subtitle using unanchored sensitiveSpanPatterns before tokenization. Then tokenize and still filter each token with anchored sensitivePatterns. Rationale: prevents PII/path fragments from slipping into searchable tags after delimiter splitting.
✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/SessionPersistence.swift">

<violation number="1" location="Sources/SessionPersistence.swift:229">
P2: Allowlisted commands are persisted with full arguments (including potential tokens/credentials), so session JSON can store sensitive CLI args unredacted.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/SessionPersistence.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
Sources/AppDelegate.swift (1)

3712-3717: The non-terminating path still writes the previous cache state.

Line 3716 kicks off a fire-and-forget refresh, but buildSessionSnapshot(...) runs immediately after this block, so non-terminating saves still serialize whatever was already in SessionForegroundProcessCache. If that one-save lag is intentional, I'd make it explicit in the helper/comment or move the async refresh after snapshot construction so the control flow matches the behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3712 - 3717, The current
fire-and-forget call to refreshForegroundProcessCacheAsync() runs before
buildSessionSnapshot(...), causing non-terminating saves to serialize the
previous SessionForegroundProcessCache; either change the non-terminating path
to call refreshForegroundProcessCacheSync() so the cache is up-to-date before
calling buildSessionSnapshot(...), or move the
refreshForegroundProcessCacheAsync() call to after buildSessionSnapshot(...) and
add a comment explaining the intentional one-save lag, so control flow and
behavior for isTerminatingApp, refreshForegroundProcessCacheSync(),
refreshForegroundProcessCacheAsync(), buildSessionSnapshot(...), and
SessionForegroundProcessCache are consistent and clear.
cmuxTests/SessionPersistenceTests.swift (1)

1096-1322: Consider adding test coverage for the enabled toggle.

The tests thoroughly cover allowlist/denylist pattern matching via isCommandAllowed(_:rawAllowlist:), but there's no test verifying that isCommandAllowed(_:defaults:) returns false when sessionRestoreCommandsEnabled is set to false in UserDefaults.

This gap means the fix from commit ac5665d (respecting the enabled toggle) isn't covered by unit tests.

📝 Suggested test addition
func testDisabledSettingBlocksAllCommands() {
    let defaults = UserDefaults(suiteName: "SessionRestoreCommandSettingsTests")!
    defaults.removePersistentDomain(forName: "SessionRestoreCommandSettingsTests")
    
    // Explicitly disable restore commands
    defaults.set(false, forKey: SessionRestoreCommandSettings.enabledKey)
    
    // Even allowlisted commands should be blocked when disabled
    XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults))
    XCTAssertFalse(SessionRestoreCommandSettings.isCommandAllowed("npm run dev", defaults: defaults))
}

func testEnabledByDefaultWhenKeyNotSet() {
    let defaults = UserDefaults(suiteName: "SessionRestoreCommandSettingsTests")!
    defaults.removePersistentDomain(forName: "SessionRestoreCommandSettingsTests")
    
    // Key not set should default to enabled
    XCTAssertTrue(SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1096 - 1322, Add unit
tests to cover the enabled toggle: create tests that set a custom UserDefaults
suite, set SessionRestoreCommandSettings.enabledKey to false and assert
SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults)
and isCommandAllowed("npm run dev", defaults: defaults) return false; also add a
test where the key is absent (removePersistentDomain) and assert
isCommandAllowed("opencode", defaults: defaults) returns true to ensure
default-enabled behavior. Reference
SessionRestoreCommandSettings.isCommandAllowed(_:defaults:) and
SessionRestoreCommandSettings.enabledKey when adding these tests.
Sources/Workspace.swift (1)

653-691: Consider centralizing restore-command normalization.

Trim + allowlist validation now lives in the restore-selection closure, the post-restore persistence block, and setPanelRestoreCommand. A small helper would keep execute/persist/reattach behavior from drifting the next time the safety rules change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 653 - 691, The restore-command trimming
and allowlist check logic is duplicated across the restore selection closure,
the persistence block, and setPanelRestoreCommand; create a single helper (e.g.,
normalizeAndValidateRestoreCommand(_:)->String?) that trims input, returns nil
for empty strings, and returns the trimmed value only if
SessionRestoreCommandSettings.isCommandAllowed(...) and use it everywhere:
replace the inline trimming/validation in the closure where commandToRestore is
computed, the post-restore persistence block that currently trims/passes to
setPanelRestoreCommand, and update setPanelRestoreCommand to accept
already-normalized input or call the helper internally so all
execute/persist/reattach code paths share the same normalization/validation
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1096-1322: Add unit tests to cover the enabled toggle: create
tests that set a custom UserDefaults suite, set
SessionRestoreCommandSettings.enabledKey to false and assert
SessionRestoreCommandSettings.isCommandAllowed("opencode", defaults: defaults)
and isCommandAllowed("npm run dev", defaults: defaults) return false; also add a
test where the key is absent (removePersistentDomain) and assert
isCommandAllowed("opencode", defaults: defaults) returns true to ensure
default-enabled behavior. Reference
SessionRestoreCommandSettings.isCommandAllowed(_:defaults:) and
SessionRestoreCommandSettings.enabledKey when adding these tests.

In `@Sources/AppDelegate.swift`:
- Around line 3712-3717: The current fire-and-forget call to
refreshForegroundProcessCacheAsync() runs before buildSessionSnapshot(...),
causing non-terminating saves to serialize the previous
SessionForegroundProcessCache; either change the non-terminating path to call
refreshForegroundProcessCacheSync() so the cache is up-to-date before calling
buildSessionSnapshot(...), or move the refreshForegroundProcessCacheAsync() call
to after buildSessionSnapshot(...) and add a comment explaining the intentional
one-save lag, so control flow and behavior for isTerminatingApp,
refreshForegroundProcessCacheSync(), refreshForegroundProcessCacheAsync(),
buildSessionSnapshot(...), and SessionForegroundProcessCache are consistent and
clear.

In `@Sources/Workspace.swift`:
- Around line 653-691: The restore-command trimming and allowlist check logic is
duplicated across the restore selection closure, the persistence block, and
setPanelRestoreCommand; create a single helper (e.g.,
normalizeAndValidateRestoreCommand(_:)->String?) that trims input, returns nil
for empty strings, and returns the trimmed value only if
SessionRestoreCommandSettings.isCommandAllowed(...) and use it everywhere:
replace the inline trimming/validation in the closure where commandToRestore is
computed, the post-restore persistence block that currently trims/passes to
setPanelRestoreCommand, and update setPanelRestoreCommand to accept
already-normalized input or call the helper internally so all
execute/persist/reattach code paths share the same normalization/validation
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 15c69e06-3b26-498b-8292-bf33958d31c9

📥 Commits

Reviewing files that changed from the base of the PR and between 26ffcf1 and ac5665d.

📒 Files selected for processing (7)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 3, 2026

@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - commit cccf205 addresses all remaining feedback:

Security (Cubic P2):

  • Added denylistContains patterns to block commands containing sensitive credentials:
    • API keys: --api-key=, --token=, --access-token=, --bearer=, --secret=
    • Passwords: --password=, --passwd=, -p (mysql-style)
    • AWS: --aws-access-key-id=, --aws-secret-access-key=, AWS_ACCESS_KEY_ID=
    • Database URLs: mongodb://, postgresql://, mysql://, redis://
    • Generic: --credentials=, --auth=
  • Commands with these patterns anywhere are now blocked from being persisted to session JSON

CodeRabbit nitpicks:

  • Added validatedRestoreCommand() helper to centralize trim + allowlist validation
  • Added tests for enabled toggle (testDisabledSettingBlocksAllCommands, testEnabledByDefaultWhenKeyNotSet, testExplicitlyEnabledAllowsCommands)
  • Clarified async refresh comment explaining intentional one-save lag for autosaves
  • Added testDenylistBlocksSensitiveCredentials and testDenylistAllowsCommandsWithoutSensitiveArgs tests

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 3, 2026

@coderabbitai @greptile-apps @cubic-dev-ai Please re-review - commit cccf205 addresses all remaining feedback:

Security (Cubic P2):

  • Added denylistContains patterns to block commands containing sensitive credentials:
    • API keys: --api-key=, --token=, --access-token=, --bearer=, --secret=
      ...

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

@mrosnerr, thanks for the detailed summary! Kicking off a fresh review of commit cccf2052 now. Here's a quick acknowledgment of the fixes:

Security (credential denylist):

  • denylistContains patterns added for API keys (--api-key=, --token=, --access-token=, --bearer=, --secret=)
  • ✅ Password patterns (--password=, --passwd=, -p mysql-style)
  • ✅ AWS credential flags (--aws-access-key-id=, --aws-secret-access-key=, AWS_ACCESS_KEY_ID=)
  • ✅ Database URL schemes (mongodb://, postgresql://, mysql://, redis://)
  • ✅ Generic auth args (--credentials=, --auth=)
  • ✅ Commands with these patterns blocked from persisting to session JSON

CodeRabbit nitpicks:

  • validatedRestoreCommand() helper centralizes trim + allowlist validation
  • ✅ Tests for enabled toggle (testDisabledSettingBlocksAllCommands, testEnabledByDefaultWhenKeyNotSet, testExplicitlyEnabledAllowsCommands)
  • ✅ Async refresh comment clarified with intentional one-save lag note
  • testDenylistBlocksSensitiveCredentials and testDenylistAllowsCommandsWithoutSensitiveArgs added

Starting the full review now.


🧠 Learnings used
Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:8178-8197
Timestamp: 2026-04-01T22:46:08.900Z
Learning: Repo: manaflow-ai/cmux — Do not persist remote-detected listening ports. In Sources/Workspace.swift, sessionPanelSnapshot must emit [] for listeningPorts when a panel is remote (panelId ∈ remoteDetectedSurfaceIds or isRemoteTerminalSurface), and applySessionPanelMetadata must not restore remote ports. Remote detections remain in-memory only; covered by SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: apollow
Repo: manaflow-ai/cmux PR: 1089
File: CLI/cmux.swift:462-499
Timestamp: 2026-03-09T02:08:54.956Z
Learning: Repo: manaflow-ai/cmux
PR: `#1089`
File: CLI/cmux.swift
Component: ClaudeHookTagExtractor.extractTags(subtitle:body:)
Learning: For Claude Code session tag extraction, pre-redact sensitive spans (UUIDs, emails, access tokens, filesystem paths, ENV_VAR=..., long numerics) across the combined body+subtitle using unanchored sensitiveSpanPatterns before tokenization. Then tokenize and still filter each token with anchored sensitivePatterns. Rationale: prevents PII/path fragments from slipping into searchable tags after delimiter splitting.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:3891-3903
Timestamp: 2026-04-02T21:37:22.443Z
Learning: Repo manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift (and similar Swift files), never log raw startup commands or initialInput in DEBUG. When diagnosing, log only presence flags and byte counts (and include surface id) to avoid leaking sensitive paths/tokens and multiline content in logs.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-01T09:50:23.728Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift within CMUXCLI.buildInteractiveRemoteShellScript(...), never export CMUX_TAB_ID from the workspace UUID. CMUX_TAB_ID must be surface-scoped: only set it when a surface ID is available (map CMUX_TAB_ID to CMUX_SURFACE_ID). Rationale: tab-action/rename-tab resolve CMUX_TAB_ID before CMUX_SURFACE_ID; workspace-scoped values misroute or fail.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:4036-4038
Timestamp: 2026-04-01T09:50:41.471Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift (buildInteractiveRemoteShellScript), remote shells intentionally export CMUX_SOCKET_PATH and CMUX_SOCKET as "127.0.0.1:<relayPort>" and prepend PATH with "$HOME/.cmux/bin"; CMUX_BUNDLED_CLI_PATH points to that wrapper. WorkspaceRemoteSessionController.remoteCLIWrapperScript() detects host:port, reads "~/.cmux/relay/<port>.daemon_path", and delegates to cmuxd-remote. Do not flag the non-UNIX value of CMUX_SOCKET_PATH/CMUX_SOCKET as a bug; it is the relay contract for remote sessions.

Learnt from: thunter009
Repo: manaflow-ai/cmux PR: 1825
File: CLI/cmux.swift:1948-1978
Timestamp: 2026-03-25T00:33:26.452Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift, the set-workspace-color command requires exactly one trailing <hex> argument and enforces a strict 6-digit hex format (`#RRGGBB`, optional leading '#'); clear-workspace-color rejects any unexpected positional args beyond --workspace. This matches server-side normalization and prevents malformed inputs.

Learnt from: outoftime
Repo: manaflow-ai/cmux PR: 1528
File: Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish:541-546
Timestamp: 2026-03-17T13:59:10.665Z
Learning: Repo: manaflow-ai/cmux — The socket command `report_git_branch` (parsed in Sources/TerminalController.swift) expects the branch name as an **unquoted, bare token**. Wrapping the branch name in double quotes causes it to be silently discarded by the parser. This matches the bash/zsh shell integration convention. Do not suggest quoting the branch argument for this command; the fix was attempted in commit 829437c7 and immediately reverted because it broke branch reporting.
✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (1)
Sources/GhosttyTerminalView.swift (1)

3158-3164: ⚠️ Potential issue | 🟠 Major

Validate the template initialInput fallback too.

The explicit path is sanitized here, but resolvedInitialInput still forwards baseConfig.initialInput unchanged. Because initial_input is also rehydrated from native surface config in Sources/Workspace.swift:29-31, a newline-bearing template value can bypass this guard and still reach surfaceConfig.initial_input. Please apply the same empty/CRLF normalization to the fallback path, or funnel both paths through one helper.

Also applies to: 3888-3895

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 3158 - 3164, The
resolvedInitialInput fallback path is not normalized so newline-bearing
templates from baseConfig.initialInput or surfaceConfig.initial_input can bypass
the CR/LF check; update resolvedInitialInput to trim whitespace/newlines and
reject empty or CR/LF-containing strings (same logic used where initialInput is
set) or refactor both assignments into a shared sanitizer helper (e.g.,
sanitizeInitialInput(_:)) and call it from places that set initialInput or
resolvedInitialInput, referencing initialInput, resolvedInitialInput,
baseConfig.initialInput and surfaceConfig.initial_input to locate where to apply
the change.
🧹 Nitpick comments (2)
Sources/AppDelegate.swift (1)

3995-4011: Minor: factor out the shared TTY collection helper.

Both methods duplicate the same mainWindowContexts walk and dedupe logic. A tiny helper keeps future filtering changes aligned between the async and sync paths.

♻️ Suggested refactor
+    private func allSessionTerminalTTYNames() -> [String] {
+        Array(Set(mainWindowContexts.values.flatMap { context in
+            context.tabManager.allTerminalTTYNames()
+        }))
+    }
+
     /// Refresh the foreground process cache for all terminal panels (async, non-blocking).
     private func refreshForegroundProcessCacheAsync() {
-        // Dedupe TTY names to avoid redundant ps scans
-        let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
-            context.tabManager.allTerminalTTYNames()
-        })
-        SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames))
+        SessionForegroundProcessCache.shared.refresh(ttyNames: allSessionTerminalTTYNames())
     }
 
     /// Refresh the foreground process cache for all terminal panels (blocking, for quit).
     private func refreshForegroundProcessCacheSync() {
-        // Dedupe TTY names to avoid redundant ps scans
-        let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
-            context.tabManager.allTerminalTTYNames()
-        })
-        SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames))
+        SessionForegroundProcessCache.shared.refreshSync(ttyNames: allSessionTerminalTTYNames())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3995 - 4011, Extract the duplicated
walk-and-dedupe logic into a small private helper (e.g. private func
allTerminalTTYNames() -> [String]) that builds the Set from
mainWindowContexts.values.flatMap { $0.tabManager.allTerminalTTYNames() } and
returns an Array of the deduped names; then update
refreshForegroundProcessCacheAsync() to call
SessionForegroundProcessCache.shared.refresh(ttyNames: allTerminalTTYNames())
and refreshForegroundProcessCacheSync() to call
SessionForegroundProcessCache.shared.refreshSync(ttyNames:
allTerminalTTYNames()) so both paths share the same collection logic.
cmuxTests/SessionPersistenceTests.swift (1)

1096-1394: Add one persistence-path regression test.

Everything here exercises SessionRestoreCommandSettings directly, but nothing asserts that a blocked detectedCommand / restoreCommand is actually omitted from the serialized session snapshot. That is the high-value boundary for this feature, and a regression there would still leave this suite green.

Based on learnings: "unallowed (potentially sensitive) commands are never written to the session JSON."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1096 - 1394, Add a
regression test that verifies blocked commands are never written to the
serialized session snapshot: create a session (using the same test harness in
SessionPersistenceTests.swift), add an entry whose
detectedCommand/restoreCommand would be blocked by
SessionRestoreCommandSettings.isCommandAllowed (e.g., "rm -rf /"), run the
session serialization/path that produces the session JSON via the existing
persistence/serializer code path, and assert the produced JSON/string does not
contain that command and that the corresponding entry is omitted; reference
SessionRestoreCommandSettings.isCommandAllowed and the
detectedCommand/restoreCommand fields to locate the right places to construct
the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1351-1377: The test currently sets allowlist = "opencode *\nnpm
*\nmysql *\naws *" so many samples (e.g., "mycli --bearer=...", "psql
--passwd=...", "mycli mongodb://...") get rejected by the allowlist before the
denylist is exercised; change the test in testDenylistBlocksSensitiveCredentials
so the allowlist does not match those samples (for example set allowlist = "" or
remove/clear the allowlist argument) so that assertAllBlocked([...], allowlist:
allowlist) actually exercises the denylist rules for the listed credential
cases.
- Around line 1357-1369: The test fixtures in SessionPersistenceTests currently
include secret-shaped literals (e.g., "opencode --api-key=sk-1234567890",
"AKIA...") which trip secret scanners; update the fixture array to avoid
checked-in secrets by either constructing fake tokens from harmless fragments at
runtime (e.g., join("sk-", "XXXX") or concatenating ["AKIA","_REDACTED"]) or
replace them with clearly redacted placeholders like "sk-REDACTED" and
"AWS_ACCESS_KEY_ID=AKIA_REDACTED" so the matcher behavior is preserved but no
real-looking secrets are committed; change the strings in the given fixture list
inside SessionPersistenceTests (the array with "opencode --api-key=...", "mycli
--bearer=...", "aws --aws-access-key-id=...") accordingly.

In `@Sources/AppDelegate.swift`:
- Around line 3712-3720: The synchronous foreground cache refresh is currently
keyed off isTerminatingApp and runs when the quit confirmation is shown; change
it so the synchronous refresh only happens on the actual confirmed termination
path (e.g. in applicationWillTerminate(_:)) instead of when isTerminatingApp is
set prior to the prompt. Concretely, revert the branch around
refreshForegroundProcessCacheSync()/refreshForegroundProcessCacheAsync() so
saveSessionSnapshot(...) uses the async refresh during the quit-confirmation
flow, and move or gate the call to refreshForegroundProcessCacheSync() to the
confirmed termination handler (applicationWillTerminate(_:)) or a separate
isConfirmedTermination flag; update references to isTerminatingApp,
refreshForegroundProcessCacheSync(), refreshForegroundProcessCacheAsync(),
saveSessionSnapshot(...), and applicationWillTerminate(_:) accordingly.

In `@Sources/SessionPersistence.swift`:
- Around line 1026-1031: The persisted restore string currently collapses
argv[0] to its basename (execName = URL(fileURLWithPath:
args[0]).lastPathComponent), which loses path-qualified executables; change it
to keep the original argv[0] (use args[0] as the executable token) and continue
to shell-quote the remaining args (shellQuoteIfNeeded), joining them as before,
so the returned string uses the full original executable token plus quoted
arguments; perform any basename normalization later in the matcher code instead
of here.
- Around line 689-690: Remove the bare "-p " and "-P " entries from the global
denylist array and instead implement command-specific checks: in the function
that decides persistence (e.g., the method that evaluates denylist tokens and
command strings, such as isCommandPersistable / shouldPersistCommand), detect
the actual command name (lowercased) and only treat "-p" as a sensitive password
flag when the command is one that uses that short flag (e.g., "mysql",
"mysqldump", "psql", etc. — add whatever concrete binaries your project intends
to block); perform a case-insensitive search for the short flag in those
command-specific branches and deny persistence for those matches. Ensure you
remove the generic "-p " / "-P " tokens so safe uses like "cargo run -p" or
"flask run -p" are not globally blocked.

In `@Sources/Workspace.swift`:
- Around line 8580-8582: The code currently rehydrates detached.restoreCommand
by calling setPanelRestoreCommand(panelId: detached.panelId, command:
restoreCommand) too early; move that write so it occurs after remote-state setup
(after didAdoptWorkspaceRemoteTracking and
transferredRemoteCleanupConfigurationsByPanelId are applied) and guard it so you
do not set a restore command when detached.isRemoteTerminal is true; in
practice, relocate the setPanelRestoreCommand call below the logic that
re-establishes remote tracking/cleanup and add a check on
detached.isRemoteTerminal (and skip writing to panelRestoreCommands for remote
terminals).
- Around line 235-247: allTerminalTTYNames() and isRemoteBackedTerminal rely
only on activeRemoteTerminalSurfaceIds and
transferredRemoteCleanupConfigurationsByPanelId, but
markRemoteTerminalSessionEnded(...) and
cleanupTransferredRemoteConnectionIfNeeded(...) currently clear those without
clearing surfaceTTYNames or restore/detectedCommand metadata, letting stale
SSH-backed ttys be treated as local at snapshot time; update the
end-of-remote-backup cleanup path to either retain a transitional remote marker
for the panel until a new local tty is detected or explicitly clear remote-only
metadata (surfaceTTYNames[panelId], any stored detectedCommand/restoreCommand
overrides, and transferredRemoteCleanupConfigurationsByPanelId entries) when
remote backing ends so allTerminalTTYNames() and isRemoteBackedTerminal won't
include stale entries; adjust markRemoteTerminalSessionEnded(...) and
cleanupTransferredRemoteConnectionIfNeeded(...) to perform this clearing or
marker retention consistently.

---

Duplicate comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 3158-3164: The resolvedInitialInput fallback path is not
normalized so newline-bearing templates from baseConfig.initialInput or
surfaceConfig.initial_input can bypass the CR/LF check; update
resolvedInitialInput to trim whitespace/newlines and reject empty or
CR/LF-containing strings (same logic used where initialInput is set) or refactor
both assignments into a shared sanitizer helper (e.g., sanitizeInitialInput(_:))
and call it from places that set initialInput or resolvedInitialInput,
referencing initialInput, resolvedInitialInput, baseConfig.initialInput and
surfaceConfig.initial_input to locate where to apply the change.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1096-1394: Add a regression test that verifies blocked commands
are never written to the serialized session snapshot: create a session (using
the same test harness in SessionPersistenceTests.swift), add an entry whose
detectedCommand/restoreCommand would be blocked by
SessionRestoreCommandSettings.isCommandAllowed (e.g., "rm -rf /"), run the
session serialization/path that produces the session JSON via the existing
persistence/serializer code path, and assert the produced JSON/string does not
contain that command and that the corresponding entry is omitted; reference
SessionRestoreCommandSettings.isCommandAllowed and the
detectedCommand/restoreCommand fields to locate the right places to construct
the test.

In `@Sources/AppDelegate.swift`:
- Around line 3995-4011: Extract the duplicated walk-and-dedupe logic into a
small private helper (e.g. private func allTerminalTTYNames() -> [String]) that
builds the Set from mainWindowContexts.values.flatMap {
$0.tabManager.allTerminalTTYNames() } and returns an Array of the deduped names;
then update refreshForegroundProcessCacheAsync() to call
SessionForegroundProcessCache.shared.refresh(ttyNames: allTerminalTTYNames())
and refreshForegroundProcessCacheSync() to call
SessionForegroundProcessCache.shared.refreshSync(ttyNames:
allTerminalTTYNames()) so both paths share the same collection logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea1faf08-76a2-40bd-aa06-9c76129f1311

📥 Commits

Reviewing files that changed from the base of the PR and between 26ffcf1 and cccf205.

📒 Files selected for processing (7)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread cmuxTests/SessionPersistenceTests.swift
Comment thread cmuxTests/SessionPersistenceTests.swift Outdated
Comment thread Sources/AppDelegate.swift
Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
Sources/Workspace.swift (1)

676-700: ⚠️ Potential issue | 🟠 Major

Use isRemoteBacked to choose the restored backend.

Lines 695-700 still route every terminal restore through newTerminalSurface(), and that helper always derives initialCommand from the workspace-wide remoteTerminalStartupCommand(). In a remote-configured workspace, a locally attached terminal will still be recreated as SSH-backed after restart; the per-panel flag currently only controls whether replay text is sent.

🛠️ Suggested change
-            guard let terminalPanel = newTerminalSurface(
+            let restoredInitialCommand = panelWasRemoteBacked ? remoteTerminalStartupCommand() : nil
+            guard let terminalPanel = newTerminalSurface(
                 inPane: paneId,
                 focus: false,
                 workingDirectory: workingDirectory,
                 startupEnvironment: replayEnvironment,
+                initialCommandOverride: restoredInitialCommand,
                 initialInput: commandToRestore
             ) else {
-    func newTerminalSurface(
+    func newTerminalSurface(
         inPane paneId: PaneID,
         focus: Bool? = nil,
         workingDirectory: String? = nil,
         startupEnvironment: [String: String] = [:],
+        initialCommandOverride: String? = nil,
         initialInput: String? = nil
     ) -> TerminalPanel? {
         let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
         let previousFocusedPanelId = focusedPanelId
         let previousHostedView = focusedTerminalPanel?.hostedView

         let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
-        let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
-        // Skip initialInput for SSH (restore text would land in prompts)
-        let safeInitialInput = remoteTerminalStartupCommand != nil ? nil : initialInput
+        let resolvedInitialCommand = initialCommandOverride ?? remoteTerminalStartupCommand()
+        // Skip initialInput for SSH (restore text would land in prompts)
+        let safeInitialInput = resolvedInitialCommand != nil ? nil : initialInput
         let newPanel = TerminalPanel(
             workspaceId: id,
             context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
             configTemplate: inheritedConfig,
             workingDirectory: workingDirectory,
             portOrdinal: portOrdinal,
-            initialCommand: remoteTerminalStartupCommand,
+            initialCommand: resolvedInitialCommand,
             initialInput: safeInitialInput,
             additionalEnvironment: startupEnvironment
         )

Based on learnings: createPanel(from:inPane:) should use the per-panel isRemoteBacked flag so local terminals attached to remote-configured workspaces restore as local panels.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 676 - 700, The restore path currently
ignores per-panel backend and always uses workspace-level
remoteTerminalStartupCommand(), causing locally attached terminals to be
recreated as SSH-backed; update createPanel(from:inPane:) (the code that calls
newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:))
to check the panel's snapshot.terminal?.isRemoteBacked (or isRemoteBacked) and
pass the correct backend decision into newTerminalSurface so that when
isRemoteBacked == false you do not derive startup backend from
remoteTerminalStartupCommand() and instead restore as a local terminal while
still preserving initialInput (commandToRestore) behavior and allowlist
validation via SessionRestoreCommandSettings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 676-700: The restore path currently ignores per-panel backend and
always uses workspace-level remoteTerminalStartupCommand(), causing locally
attached terminals to be recreated as SSH-backed; update
createPanel(from:inPane:) (the code that calls
newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:))
to check the panel's snapshot.terminal?.isRemoteBacked (or isRemoteBacked) and
pass the correct backend decision into newTerminalSurface so that when
isRemoteBacked == false you do not derive startup backend from
remoteTerminalStartupCommand() and instead restore as a local terminal while
still preserving initialInput (commandToRestore) behavior and allowlist
validation via SessionRestoreCommandSettings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b00416e3-8278-40f0-90ec-6863c9d0c1b9

📥 Commits

Reviewing files that changed from the base of the PR and between c9df396 and 4d0fd87.

📒 Files selected for processing (3)
  • Sources/GhosttyTerminalView.swift
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift

Comment thread Sources/Workspace.swift Outdated
mrosnerr added 2 commits April 5, 2026 18:33
- Set isTerminatingApp = true before every .terminateNow return and
  dialog confirmation (was only set in applicationWillTerminate)
- Simplify verbose comments in Workspace.swift session snapshot/restore
- Reduce debug logging in GhosttyTerminalView to single presence check
- Move listeningPorts and isRemoteBackedTerminal outside switch statement
…d tests

- Restore remoteDetectedSurfaceIds check for listeningPorts in snapshot
  (was accidentally removed when refactoring to isRemoteBackedForSessionSnapshot)
- Add ssh/mosh to default allowlist for session restore
- Add sshpass and :@ (user:pass@host) to denylist for security
- Add comment pointing to primary validation in SessionRestoreCommandSettings
- Restore comment explaining isTerminatingApp reset on quit cancel
- Add tests for remote-backed terminal snapshot behavior
- Add tests for SSH credential blocking and safe SSH commands
@mrosnerr mrosnerr force-pushed the feat/auto-restore-commands-core branch from 79008ae to 2cde7d4 Compare April 5, 2026 23:44
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 5, 2026

@coderabbitai review
@cubic please review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 5, 2026

@coderabbitai review
@cubic please review

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 5, 2026

@mrosnerr, kicking off a fresh review of the latest changes now!


🧠 Learnings used
Learnt from: qkrwpdlr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-23T07:12:42.553Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift and Sources/Panels/BrowserPanel.swift, BiDi override (U+202A–202E, U+2066–2069) and zero-width char (U+200B–200F, U+FEFF) filtering is implemented via a shared `dangerousScalars: Set<UInt32>` in each class. v2SanitizeWebText() truncates to 200 chars; v2SanitizeXPath() caps at 2000 chars for selector fidelity. Both use a shared v2SanitizeScalar() predicate. BrowserPickerMessageHandler.sanitize() uses the same dangerousScalars pattern with a 200-char cap.

Learnt from: qkrwpdlr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-23T07:28:45.700Z
Learning: Repo: manaflow-ai/cmux — In Sources/Panels/BrowserPanel.swift, BrowserPickerMessageHandler uses three sanitizer methods that mirror TerminalController exactly: filtered(_:) strips control chars and dangerousScalars (BiDi/zero-width); sanitizeWebText(_:) applies filtered + prefix(200) + trim for display text; sanitizeXPath(_:) applies filtered + prefix(2000) + trim for selector fidelity. sanitizeXPath is called on body["xpath"] and sanitizeWebText on body["text"] in userContentController(_:didReceive:).

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-05T21:26:10.684Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, applySessionPanelMetadata() must gate listeningPorts restoration on the per-panel snapshot.terminal?.isRemoteBacked flag (not workspace-wide remoteTerminalStartupCommand()), so that local panels are always eligible for port restore regardless of current SSH state. Fixed in commit 4d0fd871 (PR `#2545`).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: solssak
Repo: manaflow-ai/cmux PR: 2397
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-31T02:33:40.981Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, prefer ghostty_surface_text (via writePTYText) for large socket text payloads; do not manually wrap with \e[200~/\e[201~. Ghostty inserts bracketed-paste markers itself when mode 2004 is enabled. Threshold is pasteTextThreshold = 256; control scalars still go through handleControlScalar and short text through sendTextEvent.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:679-709
Timestamp: 2026-04-05T20:54:30.638Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session restore, SessionTerminalPanelSnapshot now includes isRemoteBacked. createPanel(from:inPane:) uses this per-panel flag (panelWasRemoteBacked) to: (1) pass initialInput only for local panels, and (2) persist panelRestoreCommands only for local panels. Remote-backed terminals also omit restore/detected commands and have listeningPorts [] in the snapshot.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: Sources/Workspace.swift:1072-1075
Timestamp: 2026-04-04T02:21:20.453Z
Learning: Repo: manaflow-ai/cmux — Foreground-auth deferral: In CLI/cmux.swift, PermitLocalCommand and LocalCommand are injected only into the foreground startup SSH command (e.g., via deferredRemoteReconnectLocalCommand(...)) and are not added to the ssh_options payload sent in workspace.remote.configure. Consequently, WorkspaceRemoteSSHBatchCommandBuilder.batchArguments(...) and WorkspaceRemoteSessionController.backgroundSSHOptions(_:) do not need to filter LocalCommand/PermitLocalCommand for batch operations.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-01T09:50:23.728Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift within CMUXCLI.buildInteractiveRemoteShellScript(...), never export CMUX_TAB_ID from the workspace UUID. CMUX_TAB_ID must be surface-scoped: only set it when a surface ID is available (map CMUX_TAB_ID to CMUX_SURFACE_ID). Rationale: tab-action/rename-tab resolve CMUX_TAB_ID before CMUX_SURFACE_ID; workspace-scoped values misroute or fail.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:8178-8197
Timestamp: 2026-04-01T22:46:08.900Z
Learning: Repo: manaflow-ai/cmux — Do not persist remote-detected listening ports. In Sources/Workspace.swift, sessionPanelSnapshot must emit [] for listeningPorts when a panel is remote (panelId ∈ remoteDetectedSurfaceIds or isRemoteTerminalSurface), and applySessionPanelMetadata must not restore remote ports. Remote detections remain in-memory only; covered by SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: Sources/Workspace.swift:5253-5264
Timestamp: 2026-04-04T02:19:25.076Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.parsePathHelperPaths(_:) must anchor on the actual PATH assignment (split helper output on newline/semicolon, find the fragment starting with PATH="…", then extract the quoted value) to avoid matching MANPATH or other vars that contain “PATH=”.

Learnt from: BillionClaw
Repo: manaflow-ai/cmux PR: 1755
File: Sources/GhosttyTerminalView.swift:4889-4903
Timestamp: 2026-03-18T16:50:14.396Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, GhosttyNSView.performKeyEquivalent(with:) must only route Command key equivalents to NSApp.mainMenu when shouldRouteCommandEquivalentDirectlyToMainMenu(event) returns true; otherwise, handle the key directly via keyDown to forward unbound Cmd+Shift combinations to the terminal and preserve the Cmd+` exclusion.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:0-0
Timestamp: 2026-04-05T20:53:19.847Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift inside TerminalSurface.createSurface(for:), resolvedInitialInput must:
- Append "\n" only for explicit initialInput (validated earlier to reject embedded \n/\r);
- Preserve baseConfig.initialInput exactly as provided by Ghostty (no trimming, no newline checks), to honor Ghostty’s raw-bytes startup-input semantics.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

You’re at about 92% of the monthly review limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/SessionPersistence.swift">

<violation number="1" location="Sources/SessionPersistence.swift:806">
P2: Lowercasing the entire command before MySQL password detection makes the check treat MySQL's case-sensitive "-P" (port) flag as "-p" (password), so safe commands like `mysql -P 3306` are incorrectly denied and won't be restored.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:8015">
P2: trackRemoteTerminalSurface now clears surfaceTTYNames even when a detached remote terminal has just restored a valid TTY, dropping the mapping used for remote port scans until a new TTY event arrives.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/SessionPersistence.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
Sources/AppDelegate.swift (1)

2863-2885: ⚠️ Potential issue | 🟠 Major

The precautionary async save can still clobber the final termination snapshot.

This save is still queued before the quit path is resolved. If shutdown proceeds, applicationWillTerminate(_:) does the fresh synchronous save, but the older async write can still finish afterward and overwrite it with stale foreground-process data. Guard async persistence once termination starts, or serialize both write paths through the same queue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 2863 - 2885, The async precautionary
save called in applicationShouldTerminate(_:) via
saveSessionSnapshot(includeScrollback:removeWhenEmpty:) can complete after
applicationWillTerminate(_:) performs a synchronous final save and thus
overwrite it with stale data; fix by preventing that race: when entering the
quit path set isTerminatingApp (or check that flag) before scheduling the async
save and skip or cancel the async write if termination has started, or instead
funnel both async and sync saves through a single serial persistence queue used
by saveSessionSnapshot and applicationWillTerminate so the final synchronous
save always runs after any pending async work; update
applicationShouldTerminate(_:), saveSessionSnapshot(...), and
applicationWillTerminate(_:) to use the shared queue or honor isTerminatingApp
to avoid the clobber.
cmuxTests/SessionPersistenceTests.swift (1)

1627-1668: ⚠️ Potential issue | 🟠 Major

Denylist tests still have allowlist short-circuit cases.

On Line 1643 (AWS_ACCESS_KEY_ID=... aws s3 ls) and Line 1660-1662 (sshpass ...), commands are not matched by the provided allowlists, so failures can come from allowlist rejection rather than denylist enforcement. That weakens these security regressions tests.

✅ Suggested test hardening
     func testDenylistBlocksSensitiveCredentials() {
-        // Commands with tokens/credentials anywhere should be blocked
-        let allowlist = "opencode *\nnpm *\naws *\nmycli *\npsql *\ngit *"
-        assertAllBlocked([
+        let commands = [
             // API keys and tokens
             "opencode --api-key=test-key-here",
             "opencode --token=test-token",
             "npm run dev --access-token=test-token",
             "mycli --bearer=test-bearer",
             "mycli --secret=test-secret",
             // Passwords
             "mycli --password=test-pass",
             "psql --passwd=test-pass",
             // AWS credentials
             "aws --aws-access-key-id=AKIAIOSFODNN7EXAMPLE",
             "aws --aws-secret-access-key=test-secret",
             "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE aws s3 ls",
             // Database connection strings
             "mycli mongodb://user:pass@host/db",
             "mycli postgresql://user:pass@host/db",
             // SSH keys
             "mycli --private-key=/path/to/key",
             // Sensitive file access
             "mycli cat .ssh/id_rsa",
             "mycli cat .aws/credentials",
             "mycli cat .kube/config",
-        ], allowlist: allowlist)
+        ]
+        let allowlist = commands.joined(separator: "\n")
+        assertAllBlocked(commands, allowlist: allowlist)
     }

     func testDenylistBlocksSSHWithCredentials() {
-        let allowlist = "ssh *\nmosh *"
-        assertAllBlocked([
+        let commands = [
             // sshpass password wrapper
             "sshpass -p secret ssh user@host",
             "sshpass -f /path/to/passfile ssh user@host",
             // user:pass@host syntax
             "ssh user:password@host",
             "mosh user:secret@server.com",
             // Default key paths
             "ssh -i ~/.ssh/id_rsa user@host",
             "ssh -i .ssh/id_ed25519 user@host",
-        ], allowlist: allowlist)
+        ]
+        let allowlist = commands.joined(separator: "\n")
+        assertAllBlocked(commands, allowlist: allowlist)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1627 - 1668, In
testDenylistBlocksSensitiveCredentials and testDenylistBlocksSSHWithCredentials,
some test cases (the "AWS_ACCESS_KEY_ID=... aws s3 ls" string in
testDenylistBlocksSensitiveCredentials and the "sshpass ..." / "ssh
user:password@host" entries in testDenylistBlocksSSHWithCredentials) can be
short‑circuited by the allowlist; update the tests so the denylist is actually
exercised: either remove or tighten the allowlist entries ("aws *", "ssh *",
"mosh *") used in these two tests, or move those specific command strings into
separate test cases whose allowlist intentionally does not match them; modify
the allowlist strings referenced in these tests (the local variables allowlist)
or split the test arrays in the functions testDenylistBlocksSensitiveCredentials
and testDenylistBlocksSSHWithCredentials so the denylist logic is tested
directly for the listed AWS and sshpass/username:password cases.
Sources/Workspace.swift (2)

8011-8019: ⚠️ Potential issue | 🟡 Minor

Don't clear a moved remote tab's carried TTY here.

attachDetachedSurface(...) rehydrates detached.ttyName before remote tracking is re-adopted. This unconditional clear removes it again, so the panel falls out of TTY-scoped remote port scanning after the next syncRemotePortScanTTYs() until the shell reports a new TTY. Preserve a non-empty existing TTY here, or reapply the detached TTY after trackRemoteTerminalSurface().

Based on learnings, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes, so clearing a carried TTY here causes moved remote panels to fall out of tracking on the next sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 8011 - 8019, The code in
trackRemoteTerminalSurface(_) unconditionally clears surfaceTTYNames for the
panelId (surfaceTTYNames.removeValue(forKey: panelId)), which wipes out a
detached.ttyName rehydrated earlier in attachDetachedSurface(...) and causes the
panel to drop out of TTY-scoped remote port scanning; change this so you do not
remove an existing non-empty TTY: either skip the removeValue call when
surfaceTTYNames[panelId] is non-empty or reapply detached.ttyName immediately
after tracking is adopted (e.g. call applyPendingRemoteSurfaceTTYIfNeeded(to:)
or restore the detached TTY before/after activeRemoteTerminalSurfaceIds.insert),
and ensure updateRemotePortScanTTYsLocked(_:) will see the TTY change so
remoteScannedPortsByPanel entries are updated correctly.

662-680: ⚠️ Potential issue | 🟠 Major

Per-panel remote/local identity still isn't applied during restore.

panelWasRemoteBacked only suppresses replay metadata. The actual constructor path still picks initialCommand from the workspace-wide remoteTerminalStartupCommand(), so a restored workspace cannot faithfully recreate a mix of SSH-backed terminals and detached-in local terminals. That also lets Lines 678-680 repopulate a local restore override onto a panel that may have just been recreated as remote. Thread the per-panel backend flag into terminal creation instead of only using it to gate initialInput and panelRestoreCommands.

Also applies to: 8695-8705

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 662 - 680, The restore path currently
only uses panelWasRemoteBacked to gate initialInput but still lets the
constructor pick a workspace-wide remoteTerminalStartupCommand(), so add
per-panel backend information into terminal creation: modify the call to
newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:)
to accept and pass a per-panel isRemoteBacked flag (derived from
snapshot.terminal?.isRemoteBacked as stored in panelWasRemoteBacked) and update
newTerminalSurface/terminal construction logic to prefer per-panel behavior for
choosing initialCommand (falling back to workspace
remoteTerminalStartupCommand() only when per-panel isRemoteBacked is true or
unset). Also change the post-creation restore-override logic (the
setPanelRestoreCommand(panelId:command:) conditional) to check the actual
terminalPanel.isRemoteBacked (not just panelWasRemoteBacked) so you don't apply
local restore overrides to panels created as remote; apply the same change in
the other affected block noted (around lines 8695-8705).
🧹 Nitpick comments (3)
Sources/AppDelegate.swift (1)

4026-4042: Extract the shared TTY-name collection.

Both methods duplicate the same collection/dedupe pipeline, so future filtering or normalization changes can drift between the async and sync paths.

♻️ Small cleanup
+    private func foregroundProcessCacheTTYNames() -> [String] {
+        Array(Set(mainWindowContexts.values.flatMap { context in
+            context.tabManager.allTerminalTTYNames()
+        }))
+    }
+
     /// Refresh the foreground process cache for all terminal panels (async, non-blocking).
     private func refreshForegroundProcessCacheAsync() {
-        // Dedupe TTY names to avoid redundant ps scans
-        let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
-            context.tabManager.allTerminalTTYNames()
-        })
-        SessionForegroundProcessCache.shared.refresh(ttyNames: Array(allTTYNames))
+        SessionForegroundProcessCache.shared.refresh(ttyNames: foregroundProcessCacheTTYNames())
     }
 
     /// Refresh the foreground process cache for all terminal panels (blocking, for quit).
     private func refreshForegroundProcessCacheSync() {
-        // Dedupe TTY names to avoid redundant ps scans
-        let allTTYNames = Set(mainWindowContexts.values.flatMap { context in
-            context.tabManager.allTerminalTTYNames()
-        })
-        SessionForegroundProcessCache.shared.refreshSync(ttyNames: Array(allTTYNames))
+        SessionForegroundProcessCache.shared.refreshSync(ttyNames: foregroundProcessCacheTTYNames())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 4026 - 4042, Extract the duplicated
TTY-name collection into a single helper (e.g., a private method like
collectAllTTYNames() or allTTYNames()) that performs the Set dedupe over
mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames() } and returns an Array of TTY names;
then replace the duplicated code in refreshForegroundProcessCacheAsync() and
refreshForegroundProcessCacheSync() so they each call the new helper and pass
its result to SessionForegroundProcessCache.shared.refresh(...) and
refreshSync(...), respectively. Ensure the helper is private and used only by
these two methods so future normalization/filtering changes stay in one place.
cmuxTests/SessionPersistenceTests.swift (1)

1203-1233: Isolate UserDefaults suites per test to avoid shared-state coupling.

Using one suite name ("SessionRestoreCommandSettingsTests") across tests can make behavior order-dependent under parallel runs. Prefer a per-test suite name plus cleanup via defer.

Refactor sketch
+    private func makeIsolatedDefaults(file: StaticString = `#filePath`, line: UInt = `#line`) -> (UserDefaults, String) {
+        let suite = "SessionRestoreCommandSettingsTests.\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suite)!
+        defaults.removePersistentDomain(forName: suite)
+        return (defaults, suite)
+    }

     func testDisabledSettingBlocksAllCommands() {
-        let defaults = UserDefaults(suiteName: "SessionRestoreCommandSettingsTests")!
-        defaults.removePersistentDomain(forName: "SessionRestoreCommandSettingsTests")
+        let (defaults, suite) = makeIsolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
         defaults.set(false, forKey: SessionRestoreCommandSettings.enabledKey)
         ...
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1203 - 1233, Tests
share the same UserDefaults suite "SessionRestoreCommandSettingsTests", causing
inter-test coupling; update each test to create a unique suite (e.g., append
UUID or test name when calling UserDefaults(suiteName:)), use that unique
defaults when setting/getting SessionRestoreCommandSettings.enabledKey and
SessionRestoreCommandSettings.allowlistKey, and ensure cleanup by removing the
persistent domain in a defer block (defaults.removePersistentDomain(forName:
...)) so SessionRestoreCommandSettings.isCommandAllowed uses an isolated
UserDefaults instance per test.
Sources/SessionPersistence.swift (1)

1141-1147: Minor: Duplicated normalizeTTYName implementation.

The TTY normalization logic is duplicated between SessionForegroundProcessCache.normalizeTTYName and SessionForegroundProcessDetector.normalizeTTYName. Consider extracting to a shared utility, though the current duplication is acceptable for keeping the classes independent.

Also applies to: 1073-1079

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SessionPersistence.swift` around lines 1141 - 1147, Duplicate TTY
normalization exists in SessionForegroundProcessCache.normalizeTTYName and
SessionForegroundProcessDetector.normalizeTTYName; extract the
trimming-and-/dev/ stripping logic into a single shared helper (for example a
free function normalizeTTYName(_: String) or a String extension method) and
replace both class-private implementations to call that shared helper so the
logic is centralized and the duplicate methods removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1627-1668: In testDenylistBlocksSensitiveCredentials and
testDenylistBlocksSSHWithCredentials, some test cases (the
"AWS_ACCESS_KEY_ID=... aws s3 ls" string in
testDenylistBlocksSensitiveCredentials and the "sshpass ..." / "ssh
user:password@host" entries in testDenylistBlocksSSHWithCredentials) can be
short‑circuited by the allowlist; update the tests so the denylist is actually
exercised: either remove or tighten the allowlist entries ("aws *", "ssh *",
"mosh *") used in these two tests, or move those specific command strings into
separate test cases whose allowlist intentionally does not match them; modify
the allowlist strings referenced in these tests (the local variables allowlist)
or split the test arrays in the functions testDenylistBlocksSensitiveCredentials
and testDenylistBlocksSSHWithCredentials so the denylist logic is tested
directly for the listed AWS and sshpass/username:password cases.

In `@Sources/AppDelegate.swift`:
- Around line 2863-2885: The async precautionary save called in
applicationShouldTerminate(_:) via
saveSessionSnapshot(includeScrollback:removeWhenEmpty:) can complete after
applicationWillTerminate(_:) performs a synchronous final save and thus
overwrite it with stale data; fix by preventing that race: when entering the
quit path set isTerminatingApp (or check that flag) before scheduling the async
save and skip or cancel the async write if termination has started, or instead
funnel both async and sync saves through a single serial persistence queue used
by saveSessionSnapshot and applicationWillTerminate so the final synchronous
save always runs after any pending async work; update
applicationShouldTerminate(_:), saveSessionSnapshot(...), and
applicationWillTerminate(_:) to use the shared queue or honor isTerminatingApp
to avoid the clobber.

In `@Sources/Workspace.swift`:
- Around line 8011-8019: The code in trackRemoteTerminalSurface(_)
unconditionally clears surfaceTTYNames for the panelId
(surfaceTTYNames.removeValue(forKey: panelId)), which wipes out a
detached.ttyName rehydrated earlier in attachDetachedSurface(...) and causes the
panel to drop out of TTY-scoped remote port scanning; change this so you do not
remove an existing non-empty TTY: either skip the removeValue call when
surfaceTTYNames[panelId] is non-empty or reapply detached.ttyName immediately
after tracking is adopted (e.g. call applyPendingRemoteSurfaceTTYIfNeeded(to:)
or restore the detached TTY before/after activeRemoteTerminalSurfaceIds.insert),
and ensure updateRemotePortScanTTYsLocked(_:) will see the TTY change so
remoteScannedPortsByPanel entries are updated correctly.
- Around line 662-680: The restore path currently only uses panelWasRemoteBacked
to gate initialInput but still lets the constructor pick a workspace-wide
remoteTerminalStartupCommand(), so add per-panel backend information into
terminal creation: modify the call to
newTerminalSurface(inPane:focus:workingDirectory:startupEnvironment:initialInput:)
to accept and pass a per-panel isRemoteBacked flag (derived from
snapshot.terminal?.isRemoteBacked as stored in panelWasRemoteBacked) and update
newTerminalSurface/terminal construction logic to prefer per-panel behavior for
choosing initialCommand (falling back to workspace
remoteTerminalStartupCommand() only when per-panel isRemoteBacked is true or
unset). Also change the post-creation restore-override logic (the
setPanelRestoreCommand(panelId:command:) conditional) to check the actual
terminalPanel.isRemoteBacked (not just panelWasRemoteBacked) so you don't apply
local restore overrides to panels created as remote; apply the same change in
the other affected block noted (around lines 8695-8705).

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 1203-1233: Tests share the same UserDefaults suite
"SessionRestoreCommandSettingsTests", causing inter-test coupling; update each
test to create a unique suite (e.g., append UUID or test name when calling
UserDefaults(suiteName:)), use that unique defaults when setting/getting
SessionRestoreCommandSettings.enabledKey and
SessionRestoreCommandSettings.allowlistKey, and ensure cleanup by removing the
persistent domain in a defer block (defaults.removePersistentDomain(forName:
...)) so SessionRestoreCommandSettings.isCommandAllowed uses an isolated
UserDefaults instance per test.

In `@Sources/AppDelegate.swift`:
- Around line 4026-4042: Extract the duplicated TTY-name collection into a
single helper (e.g., a private method like collectAllTTYNames() or
allTTYNames()) that performs the Set dedupe over
mainWindowContexts.values.flatMap { context in
context.tabManager.allTerminalTTYNames() } and returns an Array of TTY names;
then replace the duplicated code in refreshForegroundProcessCacheAsync() and
refreshForegroundProcessCacheSync() so they each call the new helper and pass
its result to SessionForegroundProcessCache.shared.refresh(...) and
refreshSync(...), respectively. Ensure the helper is private and used only by
these two methods so future normalization/filtering changes stay in one place.

In `@Sources/SessionPersistence.swift`:
- Around line 1141-1147: Duplicate TTY normalization exists in
SessionForegroundProcessCache.normalizeTTYName and
SessionForegroundProcessDetector.normalizeTTYName; extract the
trimming-and-/dev/ stripping logic into a single shared helper (for example a
free function normalizeTTYName(_: String) or a String extension method) and
replace both class-private implementations to call that shared helper so the
logic is centralized and the duplicate methods removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 084867ac-1b52-42ba-be7d-834beb1991ed

📥 Commits

Reviewing files that changed from the base of the PR and between 4d0fd87 and 2cde7d4.

📒 Files selected for processing (5)
  • Sources/AppDelegate.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

- Fix MySQL -P (port) vs -p (password) case sensitivity: check -p flag on
  original command instead of lowercased version to preserve case distinction
- Fix TTY clearing on detach/reattach: move surfaceTTYNames clearing from
  trackRemoteTerminalSurface() to seedInitialRemoteTerminalSessionIfNeeded()
  so detached remote terminals keep their valid TTYs
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 6, 2026

@coderabbitai review
@cubic please review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 6, 2026

@coderabbitai review
@cubic please review

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

@mrosnerr, kicking off a fresh review of the latest changes now!


🧠 Learnings used
Learnt from: qkrwpdlr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-23T07:12:42.553Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift and Sources/Panels/BrowserPanel.swift, BiDi override (U+202A–202E, U+2066–2069) and zero-width char (U+200B–200F, U+FEFF) filtering is implemented via a shared `dangerousScalars: Set<UInt32>` in each class. v2SanitizeWebText() truncates to 200 chars; v2SanitizeXPath() caps at 2000 chars for selector fidelity. Both use a shared v2SanitizeScalar() predicate. BrowserPickerMessageHandler.sanitize() uses the same dangerousScalars pattern with a 200-char cap.

Learnt from: qkrwpdlr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-23T07:28:45.700Z
Learning: Repo: manaflow-ai/cmux — In Sources/Panels/BrowserPanel.swift, BrowserPickerMessageHandler uses three sanitizer methods that mirror TerminalController exactly: filtered(_:) strips control chars and dangerousScalars (BiDi/zero-width); sanitizeWebText(_:) applies filtered + prefix(200) + trim for display text; sanitizeXPath(_:) applies filtered + prefix(2000) + trim for selector fidelity. sanitizeXPath is called on body["xpath"] and sanitizeWebText on body["text"] in userContentController(_:didReceive:).

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-05T21:26:10.684Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, applySessionPanelMetadata() must gate listeningPorts restoration on the per-panel snapshot.terminal?.isRemoteBacked flag (not workspace-wide remoteTerminalStartupCommand()), so that local panels are always eligible for port restore regardless of current SSH state. Fixed in commit 4d0fd871 (PR `#2545`).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: solssak
Repo: manaflow-ai/cmux PR: 2397
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-31T02:33:40.981Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, prefer ghostty_surface_text (via writePTYText) for large socket text payloads; do not manually wrap with \e[200~/\e[201~. Ghostty inserts bracketed-paste markers itself when mode 2004 is enabled. Threshold is pasteTextThreshold = 256; control scalars still go through handleControlScalar and short text through sendTextEvent.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:679-709
Timestamp: 2026-04-05T20:54:30.638Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session restore, SessionTerminalPanelSnapshot now includes isRemoteBacked. createPanel(from:inPane:) uses this per-panel flag (panelWasRemoteBacked) to: (1) pass initialInput only for local panels, and (2) persist panelRestoreCommands only for local panels. Remote-backed terminals also omit restore/detected commands and have listeningPorts [] in the snapshot.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: Sources/Workspace.swift:1072-1075
Timestamp: 2026-04-04T02:21:20.453Z
Learning: Repo: manaflow-ai/cmux — Foreground-auth deferral: In CLI/cmux.swift, PermitLocalCommand and LocalCommand are injected only into the foreground startup SSH command (e.g., via deferredRemoteReconnectLocalCommand(...)) and are not added to the ssh_options payload sent in workspace.remote.configure. Consequently, WorkspaceRemoteSSHBatchCommandBuilder.batchArguments(...) and WorkspaceRemoteSessionController.backgroundSSHOptions(_:) do not need to filter LocalCommand/PermitLocalCommand for batch operations.

Learnt from: apollow
Repo: manaflow-ai/cmux PR: 1089
File: CLI/cmux.swift:462-499
Timestamp: 2026-03-09T02:08:54.956Z
Learning: Repo: manaflow-ai/cmux
PR: `#1089`
File: CLI/cmux.swift
Component: ClaudeHookTagExtractor.extractTags(subtitle:body:)
Learning: For Claude Code session tag extraction, pre-redact sensitive spans (UUIDs, emails, access tokens, filesystem paths, ENV_VAR=..., long numerics) across the combined body+subtitle using unanchored sensitiveSpanPatterns before tokenization. Then tokenize and still filter each token with anchored sensitivePatterns. Rationale: prevents PII/path fragments from slipping into searchable tags after delimiter splitting.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:8178-8197
Timestamp: 2026-04-01T22:46:08.900Z
Learning: Repo: manaflow-ai/cmux — Do not persist remote-detected listening ports. In Sources/Workspace.swift, sessionPanelSnapshot must emit [] for listeningPorts when a panel is remote (panelId ∈ remoteDetectedSurfaceIds or isRemoteTerminalSurface), and applySessionPanelMetadata must not restore remote ports. Remote detections remain in-memory only; covered by SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-01T09:50:23.728Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift within CMUXCLI.buildInteractiveRemoteShellScript(...), never export CMUX_TAB_ID from the workspace UUID. CMUX_TAB_ID must be surface-scoped: only set it when a surface ID is available (map CMUX_TAB_ID to CMUX_SURFACE_ID). Rationale: tab-action/rename-tab resolve CMUX_TAB_ID before CMUX_SURFACE_ID; workspace-scoped values misroute or fail.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: Sources/Workspace.swift:5253-5264
Timestamp: 2026-04-04T02:19:25.076Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.parsePathHelperPaths(_:) must anchor on the actual PATH assignment (split helper output on newline/semicolon, find the fragment starting with PATH="…", then extract the quoted value) to avoid matching MANPATH or other vars that contain “PATH=”.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:0-0
Timestamp: 2026-04-05T20:53:19.847Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift inside TerminalSurface.createSurface(for:), resolvedInitialInput must:
- Append "\n" only for explicit initialInput (validated earlier to reject embedded \n/\r);
- Preserve baseConfig.initialInput exactly as provided by Ghostty (no trimming, no newline checks), to honor Ghostty’s raw-bytes startup-input semantics.

Learnt from: outoftime
Repo: manaflow-ai/cmux PR: 1528
File: Sources/GhosttyTerminalView.swift:3220-3228
Timestamp: 2026-03-17T18:25:33.286Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift within TerminalSurface.createSurface(for:), when constructing XDG_DATA_DIRS for Fish vendor_conf.d auto-sourcing, treat empty or whitespace-only values from initialEnvironmentOverrides, env, getenv, and ProcessInfo as unset before prefixing the integrationDir. This avoids producing a trailing colon. Keep XDG_DATA_DIRS in protectedStartupEnvironmentKeys so initialEnvironmentOverrides cannot overwrite the prefixed value.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 6, 2026

This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev.

mrosnerr added 2 commits April 6, 2026 13:25
Remove the unused restoreCommand/panelRestoreCommands infrastructure:
- Remove restoreCommand field from SessionTerminalPanelSnapshot
- Remove panelRestoreCommands dictionary from Workspace
- Remove setPanelRestoreCommand method
- Remove restoreCommand from DetachedSurfaceTransfer
- Simplify restore logic to only use detectedCommand

The restoreCommand field was reserved for a future socket API but was
never populated. All command restoration now uses auto-detected
foreground process commands (detectedCommand) which is the actual
implemented behavior.
@mrosnerr mrosnerr force-pushed the feat/auto-restore-commands-core branch from 530dc66 to 453b519 Compare April 6, 2026 17:33
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 6, 2026

@coderabbitai review
@cubic please review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 6, 2026

@coderabbitai review
@cubic please review

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

@mrosnerr, kicking off a fresh review of the latest changes now!


🧠 Learnings used
Learnt from: qkrwpdlr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-23T07:12:42.553Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift and Sources/Panels/BrowserPanel.swift, BiDi override (U+202A–202E, U+2066–2069) and zero-width char (U+200B–200F, U+FEFF) filtering is implemented via a shared `dangerousScalars: Set<UInt32>` in each class. v2SanitizeWebText() truncates to 200 chars; v2SanitizeXPath() caps at 2000 chars for selector fidelity. Both use a shared v2SanitizeScalar() predicate. BrowserPickerMessageHandler.sanitize() uses the same dangerousScalars pattern with a 200-char cap.

Learnt from: qkrwpdlr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-03-23T07:28:45.700Z
Learning: Repo: manaflow-ai/cmux — In Sources/Panels/BrowserPanel.swift, BrowserPickerMessageHandler uses three sanitizer methods that mirror TerminalController exactly: filtered(_:) strips control chars and dangerousScalars (BiDi/zero-width); sanitizeWebText(_:) applies filtered + prefix(200) + trim for display text; sanitizeXPath(_:) applies filtered + prefix(2000) + trim for selector fidelity. sanitizeXPath is called on body["xpath"] and sanitizeWebText on body["text"] in userContentController(_:didReceive:).

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:0-0
Timestamp: 2026-04-02T21:40:59.098Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session snapshot logic, detected foreground command lines are only recorded if they pass SessionRestoreCommandSettings.isCommandAllowed(...). Commit f887189 moved gating to the SessionForegroundProcessCache so unallowed (potentially sensitive) commands are never written to the session JSON.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:4965-4988
Timestamp: 2026-04-01T09:51:45.737Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.updateRemotePortScanTTYsLocked(_:) must drop remoteScannedPortsByPanel entries when a panel’s TTY changes (compare previousTTYNames vs nextTTYNames). It should also use keepPolledRemotePortsUntilTTYScan to retain the last host-wide polling snapshot only until the first TTY-scoped scan completes, and clear it when no TTYs are tracked.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 0
File: :0-0
Timestamp: 2026-04-05T21:26:10.684Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, applySessionPanelMetadata() must gate listeningPorts restoration on the per-panel snapshot.terminal?.isRemoteBacked flag (not workspace-wide remoteTerminalStartupCommand()), so that local panels are always eligible for port restore regardless of current SSH state. Fixed in commit 4d0fd871 (PR `#2545`).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: solssak
Repo: manaflow-ai/cmux PR: 2397
File: Sources/TerminalController.swift:0-0
Timestamp: 2026-03-31T02:33:40.981Z
Learning: Repo: manaflow-ai/cmux — In Sources/TerminalController.swift, prefer ghostty_surface_text (via writePTYText) for large socket text payloads; do not manually wrap with \e[200~/\e[201~. Ghostty inserts bracketed-paste markers itself when mode 2004 is enabled. Threshold is pasteTextThreshold = 256; control scalars still go through handleControlScalar and short text through sendTextEvent.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/Workspace.swift:679-709
Timestamp: 2026-04-05T20:54:30.638Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift session restore, SessionTerminalPanelSnapshot now includes isRemoteBacked. createPanel(from:inPane:) uses this per-panel flag (panelWasRemoteBacked) to: (1) pass initialInput only for local panels, and (2) persist panelRestoreCommands only for local panels. Remote-backed terminals also omit restore/detected commands and have listeningPorts [] in the snapshot.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: Sources/Workspace.swift:1072-1075
Timestamp: 2026-04-04T02:21:20.453Z
Learning: Repo: manaflow-ai/cmux — Foreground-auth deferral: In CLI/cmux.swift, PermitLocalCommand and LocalCommand are injected only into the foreground startup SSH command (e.g., via deferredRemoteReconnectLocalCommand(...)) and are not added to the ssh_options payload sent in workspace.remote.configure. Consequently, WorkspaceRemoteSSHBatchCommandBuilder.batchArguments(...) and WorkspaceRemoteSessionController.backgroundSSHOptions(_:) do not need to filter LocalCommand/PermitLocalCommand for batch operations.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-01T09:50:23.728Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift within CMUXCLI.buildInteractiveRemoteShellScript(...), never export CMUX_TAB_ID from the workspace UUID. CMUX_TAB_ID must be surface-scoped: only set it when a surface ID is available (map CMUX_TAB_ID to CMUX_SURFACE_ID). Rationale: tab-action/rename-tab resolve CMUX_TAB_ID before CMUX_SURFACE_ID; workspace-scoped values misroute or fail.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2398
File: Sources/Workspace.swift:8178-8197
Timestamp: 2026-04-01T22:46:08.900Z
Learning: Repo: manaflow-ai/cmux — Do not persist remote-detected listening ports. In Sources/Workspace.swift, sessionPanelSnapshot must emit [] for listeningPorts when a panel is remote (panelId ∈ remoteDetectedSurfaceIds or isRemoteTerminalSurface), and applySessionPanelMetadata must not restore remote ports. Remote detections remain in-memory only; covered by SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: Sources/Workspace.swift:5253-5264
Timestamp: 2026-04-04T02:19:25.076Z
Learning: Repo: manaflow-ai/cmux — In Sources/Workspace.swift, WorkspaceRemoteSessionController.parsePathHelperPaths(_:) must anchor on the actual PATH assignment (split helper output on newline/semicolon, find the fragment starting with PATH="…", then extract the quoted value) to avoid matching MANPATH or other vars that contain “PATH=”.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:0-0
Timestamp: 2026-04-05T20:53:19.847Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift inside TerminalSurface.createSurface(for:), resolvedInitialInput must:
- Append "\n" only for explicit initialInput (validated earlier to reject embedded \n/\r);
- Preserve baseConfig.initialInput exactly as provided by Ghostty (no trimming, no newline checks), to honor Ghostty’s raw-bytes startup-input semantics.

Learnt from: outoftime
Repo: manaflow-ai/cmux PR: 1528
File: Sources/GhosttyTerminalView.swift:3220-3228
Timestamp: 2026-03-17T18:25:33.286Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift within TerminalSurface.createSurface(for:), when constructing XDG_DATA_DIRS for Fish vendor_conf.d auto-sourcing, treat empty or whitespace-only values from initialEnvironmentOverrides, env, getenv, and ProcessInfo as unset before prefixing the integrationDir. This avoids producing a trailing colon. Keep XDG_DATA_DIRS in protectedStartupEnvironmentKeys so initialEnvironmentOverrides cannot overwrite the prefixed value.

Learnt from: HamptonMakes
Repo: manaflow-ai/cmux PR: 2007
File: Sources/GhosttyTerminalView.swift:3193-3196
Timestamp: 2026-03-23T16:36:13.217Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, TerminalSurface.createSurface(for:) builds a fresh per-surface environment via setManagedEnvironmentValue and does not persist managed vars (e.g., CMUX_AMP_HOOKS_DISABLED / CMUX_CLAUDE_HOOKS_DISABLED) into the template. Therefore, only setting the *_HOOKS_DISABLED var when disabled is sufficient; explicit clearing on the enabled path isn’t required.

Learnt from: mdsakalu
Repo: manaflow-ai/cmux PR: 510
File: Sources/GhosttyTerminalView.swift:2276-2336
Timestamp: 2026-03-11T04:48:32.420Z
Learning: In cmux Sources/GhosttyTerminalView.swift, when implementing initial-present recovery for Ghostty terminals, avoid ghostty_surface_draw loops. Re-read self.surface after view.forceRefreshSurface() (since the surface may be torn down/reparented), then use occlusion toggle (ghostty_surface_set_occlusion false/true) plus ghostty_surface_refresh to wake the renderer. Cancel retries once the layer has contents or the retry budget is exhausted.

Learnt from: mrosnerr
Repo: manaflow-ai/cmux PR: 2545
File: Sources/GhosttyTerminalView.swift:3891-3903
Timestamp: 2026-04-02T21:37:22.443Z
Learning: Repo manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift (and similar Swift files), never log raw startup commands or initialInput in DEBUG. When diagnosing, log only presence flags and byte counts (and include surface id) to avoid leaking sensitive paths/tokens and multiline content in logs.

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-04T00:55:26.574Z
Learning: Applies to **/GhosttyTerminalView.swift : In `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: do not add allocations, file I/O, or formatting as it is called on every keystroke

Learnt from: CR
Repo: manaflow-ai/cmux PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-25T04:48:00.216Z
Learning: Applies to **/GhosttyTerminalView.swift : `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2514
File: Sources/GhosttyTerminalView.swift:3759-3761
Timestamp: 2026-04-01T22:57:41.165Z
Learning: Repo: manaflow-ai/cmux — In Sources/cmuxApp.swift, ClaudeCodeIntegrationSettings.customClaudePath(defaults:) trims surrounding whitespace and returns nil for empty/whitespace-only values; callers (e.g., TerminalSurface.createSurface(for:)) can safely set CMUX_CUSTOM_CLAUDE_PATH without additional trimming.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

- Remove SessionForegroundProcessCache.clear() (not called anywhere)
- Remove SessionRestoreCommandSettings.defaultAllowlistText (intended for Settings UI in part 2)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
Sources/SessionPersistence.swift (1)

1236-1280: ⚠️ Potential issue | 🟠 Major

Preserve empty argv entries instead of eliding them.

Line 1252 counts empty arguments but still drops them from args, so tool "" --flag is persisted as tool --flag. That silently restores a different command.

🧩 Suggested fix
         var args: [String] = []
@@
             if byte == 0 {
                 // Always count this as an argument, even if empty
                 argCount += 1
                 if i > start {
                     let slice = Array(buffer[start..<i])
                     guard let s = String(bytes: slice, encoding: .utf8) else {
                         // Abort on first UTF-8 decode failure
                         return nil
                     }
                     args.append(s)
+                } else {
+                    args.append("")
                 }
-                // Empty strings (i == start) are counted but not added to args
                 if argCount >= Int(argc) { break }
                 start = i + 1
             }
         }
@@
     private static func shellQuoteIfNeeded(_ s: String) -> String {
+        if s.isEmpty { return "''" }
         // Characters that require quoting in shell
         let needsQuoting = s.contains { c in
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SessionPersistence.swift` around lines 1236 - 1280, The loop parsing
argv currently increments argCount for empty NUL-separated entries but omits
adding empty strings to args, so sequences like tool "" --flag become tool
--flag; modify the parsing loop (the block that uses variables buffer, offset,
argc, args, argCount, start) to append an empty string to args when i == start
(i.e., when a zero byte terminates an empty argument) instead of skipping it;
keep the existing UTF-8 decode and error behavior for non-empty slices and
preserve the argCount/stop condition, then continue to use shellQuoteIfNeeded(_)
on all preserved entries.
Sources/Workspace.swift (1)

661-671: ⚠️ Potential issue | 🟠 Major

Use panelWasRemoteBacked to choose the restore startup path.

Line 661 computes the per-panel backend, but Lines 666-671 still route every restore through newTerminalSurface(...), and Lines 8679-8688 still pick initialCommand from the workspace-wide remoteTerminalStartupCommand(). Mixed workspaces can still restore the wrong terminal type depending on the workspace’s current remote state.

🛠️ Suggested change
             guard let terminalPanel = newTerminalSurface(
                 inPane: paneId,
                 focus: false,
                 workingDirectory: workingDirectory,
+                useWorkspaceRemoteStartupCommand: panelWasRemoteBacked,
                 startupEnvironment: replayEnvironment,
                 initialInput: commandToRestore
             ) else {
                 return nil
             }
     func newTerminalSurface(
         inPane paneId: PaneID,
         focus: Bool? = nil,
         workingDirectory: String? = nil,
+        useWorkspaceRemoteStartupCommand: Bool = true,
         startupEnvironment: [String: String] = [:],
         initialInput: String? = nil
     ) -> TerminalPanel? {
         let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
         let previousFocusedPanelId = focusedPanelId
         let previousHostedView = focusedTerminalPanel?.hostedView

         let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
-        let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
+        let remoteTerminalStartupCommand =
+            useWorkspaceRemoteStartupCommand ? remoteTerminalStartupCommand() : nil
         let safeInitialInput = remoteTerminalStartupCommand != nil ? nil : initialInput
Based on learnings, `createPanel(from:inPane:)` should use the per-panel `isRemoteBacked` flag during restore so local terminals attached to remote-configured workspaces stay local.

Also applies to: 8667-8689

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 661 - 671, The restore logic currently
computes panelWasRemoteBacked but always calls newTerminalSurface(...) and later
uses workspace-wide remoteTerminalStartupCommand(), which can restore the wrong
terminal type; update createPanel(from:inPane:) to branch on
panelWasRemoteBacked (the per-panel snapshot.terminal?.isRemoteBacked flag) and
choose the per-panel restore path: if panelWasRemoteBacked use the
remote/startup flow and remoteTerminalStartupCommand() only for remote-backed
panels, otherwise use the local/newTerminalSurface(...) path and validated
per-panel command
(SessionRestoreCommandSettings.validatedRestoreCommand(snapshot.terminal?.detectedCommand))
so each panel uses its own isRemoteBacked value during restore.
🧹 Nitpick comments (2)
cmuxTests/SessionPersistenceTests.swift (2)

2411-2418: Test name is misleading.

The test testUnicodeHomoglyphAttempts doesn't actually test unicode homoglyphs—it only tests basic semicolon chaining with ASCII characters. Consider renaming to match the actual behavior being tested, or add actual homoglyph test cases (e.g., Cyrillic а vs Latin a in command names).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 2411 - 2418, The test
named testUnicodeHomoglyphAttempts is misleading because it only asserts
semicolon chaining behavior; rename the test to something like
testSemicolonChainingDisallowed or testDenylistBlocksChainedCommands (update the
XCTest method name) OR extend the test to include real homoglyph cases (e.g.,
use Cyrillic 'а' vs Latin 'a' in the command string) and assert the same
behavior via SessionRestoreCommandSettings.isCommandAllowed(_:rawAllowlist:);
modify assertions accordingly so the test name matches the actual inputs and
intent.

1373-1380: Remove unused variable.

allowlist on line 1375 is declared but never used. The test correctly uses safeAllowlist instead.

🧹 Suggested fix
 func testPatternMatchingPreservesArgumentSpaces() {
     // Arguments with multiple spaces should be preserved
-    let allowlist = "echo *"
     // Note: echo is blocked by denylist in some contexts, use a safe command
     let safeAllowlist = "myecho *"
     XCTAssertTrue(SessionRestoreCommandSettings.isCommandAllowed("myecho hello world", rawAllowlist: safeAllowlist))
     XCTAssertTrue(SessionRestoreCommandSettings.isCommandAllowed("myecho 'hello   world'", rawAllowlist: safeAllowlist))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 1373 - 1380, In
testPatternMatchingPreservesArgumentSpaces remove the unused local variable
allowlist (declared in the test) since the test uses safeAllowlist and allowlist
is never referenced; update the function
testPatternMatchingPreservesArgumentSpaces to delete the allowlist declaration
so only safeAllowlist is used, leaving the assertions that call
SessionRestoreCommandSettings.isCommandAllowed unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/SessionPersistence.swift`:
- Around line 526-527: The denylist currently contains entries like "ssh *" and
"mosh *" but does not guard against dangerous ssh options; update the
denylist/validation where "ssh *" and "mosh *" are defined to also reject any
ssh invocation that includes LocalCommand, PermitLocalCommand, or ProxyCommand
(e.g., detect "-o LocalCommand=", "-o PermitLocalCommand=", "-o ProxyCommand="
or any appearance of those keywords) so commands like ssh -o LocalCommand='...'
host are denied; apply the same change to the other denylist block mentioned
(lines ~633–658) so both definitions validate option strings, and ensure the
validation logic trims/normalizes args before matching.

In `@Sources/Workspace.swift`:
- Around line 7988-7999: The promotion to a remote-backed terminal currently
removes the stale local TTY but leaves any local port metadata in
surfaceListeningPorts, causing stale listeners to remain; update the promotion
path (e.g., in trackRemoteTerminalSurface(_:) or immediately before it is
called) to remove the surfaceListeningPorts entry for the panel (use the same
panel identifier, e.g., initialPanelId / panelId) by calling
surfaceListeningPorts.removeValue(forKey: panelId) so local listener metadata is
cleared when a surface becomes remote-backed; keep the existing comment about
not clearing TTYs but ensure port metadata is explicitly removed.

---

Duplicate comments:
In `@Sources/SessionPersistence.swift`:
- Around line 1236-1280: The loop parsing argv currently increments argCount for
empty NUL-separated entries but omits adding empty strings to args, so sequences
like tool "" --flag become tool --flag; modify the parsing loop (the block that
uses variables buffer, offset, argc, args, argCount, start) to append an empty
string to args when i == start (i.e., when a zero byte terminates an empty
argument) instead of skipping it; keep the existing UTF-8 decode and error
behavior for non-empty slices and preserve the argCount/stop condition, then
continue to use shellQuoteIfNeeded(_) on all preserved entries.

In `@Sources/Workspace.swift`:
- Around line 661-671: The restore logic currently computes panelWasRemoteBacked
but always calls newTerminalSurface(...) and later uses workspace-wide
remoteTerminalStartupCommand(), which can restore the wrong terminal type;
update createPanel(from:inPane:) to branch on panelWasRemoteBacked (the
per-panel snapshot.terminal?.isRemoteBacked flag) and choose the per-panel
restore path: if panelWasRemoteBacked use the remote/startup flow and
remoteTerminalStartupCommand() only for remote-backed panels, otherwise use the
local/newTerminalSurface(...) path and validated per-panel command
(SessionRestoreCommandSettings.validatedRestoreCommand(snapshot.terminal?.detectedCommand))
so each panel uses its own isRemoteBacked value during restore.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 2411-2418: The test named testUnicodeHomoglyphAttempts is
misleading because it only asserts semicolon chaining behavior; rename the test
to something like testSemicolonChainingDisallowed or
testDenylistBlocksChainedCommands (update the XCTest method name) OR extend the
test to include real homoglyph cases (e.g., use Cyrillic 'а' vs Latin 'a' in the
command string) and assert the same behavior via
SessionRestoreCommandSettings.isCommandAllowed(_:rawAllowlist:); modify
assertions accordingly so the test name matches the actual inputs and intent.
- Around line 1373-1380: In testPatternMatchingPreservesArgumentSpaces remove
the unused local variable allowlist (declared in the test) since the test uses
safeAllowlist and allowlist is never referenced; update the function
testPatternMatchingPreservesArgumentSpaces to delete the allowlist declaration
so only safeAllowlist is used, leaving the assertions that call
SessionRestoreCommandSettings.isCommandAllowed unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c1def629-bab7-4f28-9e66-c3e19144d9f4

📥 Commits

Reviewing files that changed from the base of the PR and between 12b3a0a and 453b519.

📒 Files selected for processing (3)
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment on lines +526 to +527
"ssh *",
"mosh *",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine Sources/SessionPersistence.swift
fd "SessionPersistence.swift" -x wc -l {}

Repository: manaflow-ai/cmux

Length of output: 99


🏁 Script executed:

# Read the denylist definition and surrounding context (around line 526)
sed -n '510,570p' Sources/SessionPersistence.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 2626


🏁 Script executed:

# Also check the second range mentioned (633-658)
sed -n '620,670p' Sources/SessionPersistence.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 2407


🏁 Script executed:

# Search for the actual denylistContains definition and its full content
rg -n "denylistContains\s*=" Sources/SessionPersistence.swift -A 20

Repository: manaflow-ai/cmux

Length of output: 988


🏁 Script executed:

# Check for command matching logic and how denylists are applied
rg -n "denylistContains|commandMatchesPattern" Sources/SessionPersistence.swift -B 2 -A 5

Repository: manaflow-ai/cmux

Length of output: 1847


🏁 Script executed:

# Get the complete denylistContains array
sed -n '631,750p' Sources/SessionPersistence.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 5501


🏁 Script executed:

# Also check if there's any case-insensitive matching or lowercasing in the validation logic
rg -n "localcommand|permitlocalcommand|proxycommand" Sources/SessionPersistence.swift -i

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check the isCommandAllowed validation flow to understand how denylist is applied
rg -n "isCommandAllowed|denylistContains" Sources/SessionPersistence.swift -B 3 -A 10

Repository: manaflow-ai/cmux

Length of output: 4841


🏁 Script executed:

# Verify the exact line numbers for the denylist segment mentioned in "Also applies to: 633-658"
sed -n '633,658p' Sources/SessionPersistence.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 1268


🏁 Script executed:

# Check if there are any other places in the codebase where these SSH options are validated
rg -n "localcommand|permitlocalcommand|proxycommand" -i --type swift

Repository: manaflow-ai/cmux

Length of output: 203


🏁 Script executed:

# Check the context around those lines in TerminalSSHSessionDetector.swift
sed -n '450,470p' Sources/TerminalSSHSessionDetector.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 980


🏁 Script executed:

# Also check what class/function these are in and their purpose
sed -n '440,480p' Sources/TerminalSSHSessionDetector.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 1700


🏁 Script executed:

# Search for proxycommand anywhere in the codebase
rg -n "proxycommand" -i --type swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Check what filteredSSHOptionKeys is used for - understand its purpose
rg -n "filteredSSHOptionKeys" Sources/TerminalSSHSessionDetector.swift -B 5 -A 10

Repository: manaflow-ai/cmux

Length of output: 1288


Add SSH helper-execution options to denylist.

The allowlist includes ssh *, but the denylist does not block LocalCommand, PermitLocalCommand, or ProxyCommand. A command like ssh -o LocalCommand='touch /tmp/cmux' host will pass validation and be restored on subsequent session startup, re-executing the embedded local command.

🔒 Suggested hardening
     private static let denylistContains = [
         // API keys and tokens
         "--api-key=", "--api-key ",
         "--apikey=", "--apikey ",
@@
         // SSH password wrappers and inline credentials
         "sshpass ",
         ":@",  // user:pass@host syntax
+        // SSH options that execute local helpers or replace the local transport
+        "localcommand=", "localcommand ",
+        "permitlocalcommand=", "permitlocalcommand ",
+        "proxycommand=", "proxycommand ",
         // Generic auth
         "--credentials=", "--credentials ",
         "--auth=", "--auth ",

Also applies to: 633–658

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SessionPersistence.swift` around lines 526 - 527, The denylist
currently contains entries like "ssh *" and "mosh *" but does not guard against
dangerous ssh options; update the denylist/validation where "ssh *" and "mosh *"
are defined to also reject any ssh invocation that includes LocalCommand,
PermitLocalCommand, or ProxyCommand (e.g., detect "-o LocalCommand=", "-o
PermitLocalCommand=", "-o ProxyCommand=" or any appearance of those keywords) so
commands like ssh -o LocalCommand='...' host are denied; apply the same change
to the other denylist block mentioned (lines ~633–658) so both definitions
validate option strings, and ensure the validation logic trims/normalizes args
before matching.

Comment thread Sources/Workspace.swift Outdated
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 6, 2026

Regarding Cubic's P2 about 'Allowlisted commands persisted with full arguments including tokens/credentials':

This is already addressed by the denylist. The SessionForegroundProcessCache only caches commands that pass isCommandAllowed(), which calls isCommandDenied() first. The denylist blocks:

  • --token=, --api-key=, --password=, --secret=
  • --aws-access-key-id=, --aws-secret-access-key=
  • --credentials=, --auth=, --private-key=
  • Database URLs (mongodb://, postgresql://, etc.)
  • MySQL -p flag (case-sensitive)
  • And more (see denylistContains in SessionPersistence.swift)

Commands containing any of these patterns are never cached or persisted, regardless of allowlist status.

@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 6, 2026

@cubic please review

@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 6, 2026

@cubic please review

@mrosnerr I have started the AI code review. It will take a few minutes to complete.

@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 6, 2026

All Review Comments Addressed

Greptile Issues (Apr 2)

  • P1: watch * bypass - Removed from allowlist in f887189
  • P1: isEnabled() skipped - Fixed in f887189
  • P2: UTF-8 byte mangling - Fixed in f887189 (proper UTF-8 decoding)
  • P2: Space quoting - Fixed in f887189 (shellQuoteIfNeeded)

CodeRabbit Issues (Apr 2-3)

  • Major: Consume restore input after first use - Fixed in f887189
  • Major: Redact DEBUG logs - Fixed in f887189
  • Major: Move detection off main thread - Fixed via SessionForegroundProcessCache
  • Major: Don't persist raw commands - Only allowlisted commands cached
  • Major: Carry panelRestoreCommands through detach - Removed (simplified to detectedCommand only)
  • Minor: ps on main thread - Fixed via async cache refresh
  • Nitpick: One-save lag comment - Already documented in code
  • Duplicate: Skip listeningPorts for remote - Already implemented via isRemoteBacked

Cubic Issues (Apr 3-6)

  • P2: MySQL -P vs -p - Fixed in 12b3a0a (case-sensitive check)
  • P2: TTY clearing on detach/reattach - Fixed in 12b3a0a
  • P2: Credentials in persisted commands - Already addressed by denylist

Simplifications Made

  • Removed unused restoreCommand infrastructure (only detectedCommand used)
  • Removed unused clear() method and defaultAllowlistText

All conversations can be resolved.

@mrosnerr mrosnerr force-pushed the feat/auto-restore-commands-core branch from a08786e to 01aed04 Compare April 6, 2026 18:25
@mrosnerr
Copy link
Copy Markdown
Author

mrosnerr commented Apr 6, 2026

@austinywang — this is ready for your review when you have a chance. Part 2 (Settings UI + translations) is on hold pending your feedback on the approach here.

Resolve 10 merge conflicts across 5 files where auto-restore
commands (PR manaflow-ai#2545) and restorable agent resume (main) both use
the new initialInput parameter.

Key merge decisions:
- SessionTerminalPanelSnapshot: keep all three new fields
  (detectedCommand, isRemoteBacked, agent)
- TerminalSurface.normalizedInitialInput: unify newline conventions
  between callers (agent resume sends cmd+\n, restore sends bare cmd)
  with defense-in-depth injection prevention
- Workspace restore: agent resume takes precedence over detected
  command via combinedInitialInput coalescing
- Remove duplicate initialInput property (main let + PR var)
- Keep PR safeInitialInput gating for remote-backed terminals

Add TerminalSurfaceNormalizedInitialInputTests to guard the
newline normalization contract.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant